Skip to content

Entities

An entity is a SeaORM model decorated with #[expose]. You declare the columns, their types, the relations, and which fields cross the wire — once — and the macro emits everything downstream: the wire DTO, the create/update inputs, the validator wiring, the JSON schema the HTTP and OpenAPI layers read, the GraphQL object and its relation dataloaders, and the lifecycle behaviour (timestamps, soft delete).

The entity is the wire contract. There is no separate DTO to keep in sync — a column reaches a transport only when it carries #[expose], and silence means hidden. That is the deliberate posture: a column a later migration adds never leaks by omission.

The reference entity lives at crates/features/src/posts/entity.rs. Copy it before inventing a second shape.

crates/features/src/posts/entity.rs
use nest_rs_resource::expose;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[expose(
name = "Post",
service = super::service::PostsService,
graphql,
soft_delete,
timestamps
)]
#[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]
pub org_id: Uuid,
#[expose]
pub author_id: Uuid,
#[expose(input(create, update), validate(length(min = 1)))]
pub title: String,
#[expose(input(create, update), validate(length(min = 1)))]
pub body: String,
#[expose]
pub created_at: DateTimeWithTimeZone,
#[expose]
pub updated_at: DateTimeWithTimeZone,
pub deleted_at: Option<DateTimeWithTimeZone>,
#[sea_orm(belongs_to, from = "org_id", to = "id")]
#[expose]
pub org: HasOne<crate::orgs::Entity>,
#[sea_orm(belongs_to, from = "author_id", to = "id")]
#[expose]
pub author: HasOne<crate::users::Entity>,
}

SeaORM owns the column types and the #[sea_orm(...)] attributes — #[expose] only reads them and decides what crosses the wire. The two decorators compose: #[sea_orm::model] builds the Entity, Column, ActiveModel, and Relation machinery; #[expose] builds the transport surface on top. For everything about columns, types, relations, and migrations, the official SeaORM 2 documentation is the source of truth.

#[expose(...)] takes one required value, one near-required path, and a set of bare flags.

FlagKindEffect
name = "Post"requiredThe wire DTO / GraphQL object / OpenAPI schema name.
service = …pathThe service that owns the entity. Required as soon as the entity exposes a relation or another feature needs its PK loader — it’s where the macro emits the loaders.
graphqlflagEmit the GraphQL object + input objects, and auto-resolve exposed relations through dataloaders. An exposed relation without graphql is a compile error.
timestampsflagEmit an ActiveModelBehavior that stamps created_at on insert and updated_at on every save. Requires both columns.
soft_deleteflagEmit impl SoftDeletable so deletes set deleted_at and reads filter tombstoned rows. Requires a deleted_at: Option<…> column.
paginateflagEmit a <Name>Page wrapper type for paginated list endpoints. See Pagination.
complexflagForce #[graphql(complex)] on the wire DTO. Auto-enabled when graphql is set and the entity has auto-resolved relations — you rarely set it by hand.

Every product entity in this repo (org, user, post) carries the same four columns. Follow the convention unless you have a reason not to:

ColumnTypeRole
idUuidPrimary key, auto_increment = false. The create path seeds it with Uuid::now_v7() when the input omits it — time-ordered, index-friendly.
created_atDateTimeWithTimeZoneStamped once on insert by timestamps. Read-only on the wire.
updated_atDateTimeWithTimeZoneStamped on every save by timestamps. Read-only on the wire.
deleted_atOption<DateTimeWithTimeZone>The soft-delete tombstone. Left unexposed — it’s server-side state, never a wire field.
#[sea_orm(primary_key, auto_increment = false)]
#[expose]
pub id: Uuid,
// …
#[expose]
pub created_at: DateTimeWithTimeZone,
#[expose]
pub updated_at: DateTimeWithTimeZone,
pub deleted_at: Option<DateTimeWithTimeZone>, // no #[expose] — hidden

id, created_at, and updated_at are exposed read-only (bare #[expose], no input(...)) — the client reads them, never writes them. deleted_at stays off the wire entirely.

A field crosses HTTP, GraphQL, and WS only when it carries #[expose]. No attribute means the field is hidden from every transport.

Field attributeOn the wireIn inputs
#[expose]yes (read-only)no
#[expose(input(create))]yesCreateDto only
#[expose(input(update))]yesUpdateDto only
#[expose(input(create, update))]yesboth
(no #[expose])hiddenno

input(...) implies read — a writable field is always readable. The ordering of create / update is free.

Hidden columns are the security-load-bearing case. A password_hash, a role set from a JWT, an internal flag — none of these should ride a response. Because exposure is opt-in, they stay off the wire by default, and a column a future migration adds never leaks until someone deliberately exposes it.

input(create) / input(update) place a field in the generated Create<Name>Input / Update<Name>Input structs. validate(...) attaches a validator rule, re-emitted verbatim onto the input field:

#[expose(input(create, update), validate(length(min = 1)))]
pub title: String,
#[sea_orm(unique)]
#[expose(input(create, update), validate(email))]
pub email: String,

The input structs derive Validate. Validation runs at the HTTP edge when the handler binds the body through Valid<Json<CreateDto>> — the service never sees an invalid input. The full rule set (length, email, range, url, custom validators, …) is the validator crate’s; see its docs for the catalogue.

A SeaORM belongs_to / has_many field, typed HasOne<E> / HasMany<E> and marked #[expose], becomes a GraphQL field auto-resolved by a dataloader — no #[field_resolver] to write:

#[sea_orm(belongs_to, from = "org_id", to = "id")]
#[expose]
pub org: HasOne<crate::orgs::Entity>,
#[sea_orm(has_many)]
#[expose]
pub posts: HasMany<crate::posts::Entity>,

This needs graphql and service on the header. The macro emits the PK loader (<Service>ById) and, per belongs_to, the FK loader (<Service>By<FkCol>) on the owning side’s service, plus the PkLoadable / RelatedTo<Parent> impls that let the inverse side reach the loader without naming the other service. Every batch goes through Repo::scoped(Action::Read), so the ambient ability filters relation reads row-level, exactly as on any other query. Drop #[expose] on a single relation to opt that one field out.

See GraphQL relations and Dataloaders for the full mechanics, and Query limits for the per-relation complexity cost the macro attaches.

There is no #[expose] “virtual column” attribute — #[expose] only surfaces real SeaORM columns and relations. For a value computed at query time (a derived total, a formatted label, a cursor connection over a relation), leave the source unexposed and write a #[field_resolver] on the resolver instead. Cost it with #[expose(complexity = …)] on an exposed field, or #[graphql(complexity = …)] directly on a hand-rolled resolver.

SeaORM itself offers two lower-level mechanisms underneath, neither of which #[expose] reads — wire them through a #[field_resolver] (or a hand-shaped HTTP response) if you want them on the wire:

  • A non-persisted field on the Model#[sea_orm(ignore)] keeps a field on the struct but out of the table and every query (set to Default::default() on load). Useful for transient state carried on the model.
  • A value computed by the database — a DerivePartialModel with #[sea_orm(from_expr = "…")] selects a SQL expression into a result struct; a plain method on impl Model covers values computed in Rust.

See the official SeaORM 2 documentation for both.

timestamps and soft_delete are independent flags that emit SeaORM-level behaviour:

  • timestamps emits ActiveModelBehavior::before_save, stamping created_at on insert and updated_at on every save with chrono::Utc::now().fixed_offset().
  • soft_delete emits impl SoftDeletable for Entity (declaring the deleted_at column). CrudService deletes by setting the tombstone, and reads AND deleted_at IS NULL onto every query so a soft-deleted row is invisible — in lists, by-id loads, and relation batches alike.

#[expose] builds the wire surface; it does not create the table. The columns it reads must exist in a migration — including the lifecycle columns, with their database-side defaults:

.col(ColumnDef::new(Post::CreatedAt)
.timestamp_with_time_zone().not_null()
.default(Expr::current_timestamp()))
.col(ColumnDef::new(Post::UpdatedAt)
.timestamp_with_time_zone().not_null()
.default(Expr::current_timestamp()))
.col(ColumnDef::new(Post::DeletedAt)
.timestamp_with_time_zone().null())

See Migrations for writing and running them (nestrs run db up).

AttributeWhereEffect
#[expose]any columnRead-only on the wire.
#[expose(input(create, update))]scalar columnAdds to wire + the named input(s).
#[expose(validate(…))]input columnAttaches a validator rule to the input field.
#[expose(complexity = …)]exposed field / relationOverrides the GraphQL complexity cost (literal or expression string).
#[expose] on HasOne/HasManyrelationAuto-resolved GraphQL field (needs header graphql + service).