Skip to content

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.

Terminal window
cargo add nest-rs-seaorm nest-rs-resource
src/posts/entity.rs
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.

REST/OpenAPI adopters can compile without pulling in GraphQL:

Cargo.toml
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"] }
src/posts/entity.rs
#[expose(name = "Post", service = super::service::PostsService)]
// no `graphql` flag — wire DTO + CRUD inputs only
pub 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)]
src/posts/service.rs
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.

src/posts/module.rs
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:

src/posts/http/module.rs
#[module(imports = [PostsModule], providers = [PostsController])]
pub struct PostsHttpModule;

The app root activates the database and lists the slice:

src/module.rs
#[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.

Importing DatabaseModule activates four invariants every handler in the slice inherits, without writing any of them:

  • Row-level filtering. Every Repo read runs with the caller’s Ability::condition_for ANDed 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.
  • Repo and executor — the ambient executor, the DbContext interceptor, the contextless-path escape hatch.
  • Dataloaders#[dataloader] batches, the auto-emitted PK and FK loaders, the relations that resolve themselves.
  • Pagination — keyset Page<M> and PageArgs, REST vs GraphQL.
  • Transactions — auto-commit, the retry_serialization_conflicts flag, the retry_on_conflict primitive.
  • HealthDatabaseHealthModule and 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.