Database
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-seaorm nest-rs-resourceThe entity
Section titled “The entity”use nest_rs_resource::expose;use sea_orm::entity::prelude::*;use serde::{Deserialize, Serialize};use uuid::Uuid;
#[expose(name = "Post", service = super::service::PostsService)]#[sea_orm::model]#[derive(Clone, Debug, DeriveEntityModel)]#[sea_orm( table_name = "post", model_attrs(derive(PartialEq, Serialize, Deserialize)))]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 CreatePost) — 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:
nest-rs-resource = { version = "0.2", default-features = false }nest-rs-seaorm = { version = "0.2", features = ["http"] }nest-rs-authz = { version = "0.2", features = ["http"] }#[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:
nest-rs-resource = { version = "0.2", features = ["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 = [ DatabaseModule::for_root(None), PostsModule, PostsHttpModule, ],)]pub struct AppModule;DatabaseModule::for_root(None) reads NESTRS_DATABASE__URL (and the other
NESTRS_DATABASE__* keys) from the environment; pass a DatabaseConfig to
pin values in code — the framework-wide dual path applies.
What the data layer hands you for free
Section titled “What the data layer hands you for free”Importing DatabaseModule 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.
Going further
Section titled “Going further”- Repo and executor — the ambient executor,
the
DbContextinterceptor, the contextless-path escape hatch. - Dataloaders —
#[dataloader]batches, the auto-emitted PK and FK loaders, the relations that resolve themselves. - Pagination — keyset
Page<M>andPageArgs, REST vs GraphQL. - Transactions — auto-commit, the
retry_serialization_conflictsflag, theretry_on_conflictprimitive. - Health —
DatabaseHealthModuleand the readiness probe. - CRUD —
#[crud(...)]on a controller or resolver. - Migrations and Seeding — schema and demo data.
- Writing a driver — plugging a non-SeaORM store into the same seams.