Skip to content

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.

Terminal window
cargo add nest-rs --features seaorm,http

That 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.

crates/features/src/posts/entity.rs
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.

REST/OpenAPI adopters can compile without pulling in GraphQL:

Terminal window
cargo add nest-rs --features http,seaorm,authz
crates/features/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:

Terminal window
cargo add nest-rs --features seaorm,graphql
crates/features/src/posts/entity.rs
#[expose(name = "Post", service = super::service::PostsService, graphql)]
crates/features/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.

crates/features/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:

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

The app root activates the database and lists the slice:

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

Importing SeaOrmDatabaseModule 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.
  • 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 DbContext interceptor, 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.
  • Dataloaders#[dataloader] batches, and the PK and FK loaders #[expose] emits.
  • Transactions — auto-commit, conflict observation, the retry_on_conflict primitive.
  • Seeding — idempotent demo data through the seed crate.
  • Database healthSeaOrmHealthModule and the readiness probe.
  • Writing a driver — plugging a non-SeaORM store into the same seams.
  • Authorization — the ability every Repo read 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.