Entities
The entity is the wire contract. One #[expose]d SeaORM model emits the DTO, inputs, JSON schema, relation loaders, and lifecycle — opt-in, fail-secure.
An entity is a SeaORM model decorated with #[expose]. You declare the columns, their types, the relations, and which fields cross
the wire — once. The macro emits everything downstream:
- the wire DTO, and the create/update inputs;
- the
validatorwiring; - the JSON schema the HTTP and OpenAPI layers read;
- the GraphQL object and its relation dataloaders;
- 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.
Building your first entity? Start with the
tutorial walkthrough — it grows a minimal Post
step by step. This page is the reference: every header flag, every
field attribute, relations, validation, lifecycle, and the columns we
recommend on every entity.
Anatomy
Section titled “Anatomy”The reference entity lives at crates/features/src/posts/entity.rs.
Copy it before inventing a second shape.
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.
The header
Section titled “The header”#[expose(...)] takes one required value, one near-required path, and
a set of bare flags.
| Flag | Kind | Effect |
|---|---|---|
name = "Post" | required | The wire DTO / GraphQL object / OpenAPI schema name. |
service = … | path | The 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. |
graphql | flag | Emit the GraphQL object + input objects, and auto-resolve exposed relations through dataloaders. An exposed relation without graphql is a compile error. |
timestamps | flag | Emit an ActiveModelBehavior that stamps created_at on insert and updated_at on every save. Requires both columns. |
soft_delete | flag | Emit impl SoftDeletable so deletes set deleted_at and reads filter tombstoned rows. Requires a deleted_at: Option<…> column. |
complex | flag | Force #[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. |
Recommended standard columns
Section titled “Recommended standard columns”Every product entity in this repo (org, user, post) carries the
same four columns. Follow the convention unless you have a reason not
to:
| Column | Type | Role |
|---|---|---|
id | Uuid | Primary key, auto_increment = false. The create path seeds it with Uuid::now_v7() when the input omits it — time-ordered, index-friendly. |
created_at | DateTimeWithTimeZone | Stamped once on insert by timestamps. Read-only on the wire. |
updated_at | DateTimeWithTimeZone | Stamped on every save by timestamps. Read-only on the wire. |
deleted_at | Option<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] — hiddenid, 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.
Read side — exposure is opt-in
Section titled “Read side — exposure is opt-in”A field crosses HTTP, GraphQL, and WS only when it carries
#[expose]. No attribute means the field is hidden from every
transport.
| Field attribute | On the wire | In inputs |
|---|---|---|
#[expose] | yes (read-only) | no |
#[expose(input(create))] | yes | Create<Name> only |
#[expose(input(update))] | yes | Update<Name> only |
#[expose(input(create, update))] | yes | both |
(no #[expose]) | hidden | no |
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.
Enum columns
Section titled “Enum columns”An exposed column whose type is your own enum passes through to the wire
verbatim, so the enum is what has to carry the wire traits.
#[wire_enum] is the enum mode of #[expose] and emits all of them —
Serialize, Deserialize, JsonSchema, and under graphql
async_graphql::Enum, plus the Clone/Copy/Eq shape those require:
use nest_rs::resource::{expose, wire_enum};
#[wire_enum(graphql)]#[derive(EnumIter, DeriveActiveEnum)]#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]#[serde(rename_all = "lowercase")]pub enum PostStatus { #[sea_orm(string_value = "draft")] Draft, #[sea_orm(string_value = "published")] Published,}Each of those derives expands against the call site’s prelude, so
hand-writing them put schemars and async-graphql in your manifest for
code you never wrote. The decorator routes them back through
nest-rs-resource with a crate = override, so your manifest names
neither.
It deliberately emits nothing from SeaORM: EnumIter,
DeriveActiveEnum, #[sea_orm(rs_type, db_type)] and the per-variant
string_value stay yours, because how the column is stored is your
decision, not the wire’s. Your own #[serde(rename_all = …)] and
#[derive(Default)] sit beside it untouched. Drop graphql for an enum
that only crosses HTTP.
Write side — inputs and validation
Section titled “Write side — inputs and validation”input(create) / input(update) place a field in the generated
Create<Name> / Update<Name> structs. These entity-derived inputs are
bare — no Dto or Input suffix — because one struct serves the
service (type Create), the REST body, and the GraphQL input at once, so no
single boundary suffix fits. 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<CreatePost>> —
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 relation field cannot carry input(...) — only scalar columns
are writable. Set the foreign-key column (org_id) as the input, not
the relation (org).
Relations
Section titled “Relations”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, Via> impls that let the inverse side
reach the loader without naming the other service. The has_many side
is a paged connection (first / after), one page per parent.
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, and add
#[expose(via = "author_id")] when the child reaches this parent through
more than one foreign key.
See GraphQL relations and Dataloaders for the full mechanics, and Query limits for the per-relation complexity cost the macro attaches.
Virtual and computed fields
Section titled “Virtual and computed fields”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 filtered subset of 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 toDefault::default()on load). Useful for transient state carried on the model. - A value computed by the database — a
DerivePartialModelwith#[sea_orm(from_expr = "…")]selects a SQL expression into a result struct; a plain method onimpl Modelcovers values computed in Rust.
See the official SeaORM 2 documentation for both.
Lifecycle behaviour
Section titled “Lifecycle behaviour”timestamps and soft_delete are independent flags that emit
SeaORM-level behaviour:
timestampsemitsActiveModelBehavior::before_save, stampingcreated_aton insert andupdated_aton every save withchrono::Utc::now().fixed_offset().soft_deleteemitsimpl SoftDeletable for Entity(declaring thedeleted_atcolumn). This is only half the wiring: the entity flag makes the column addressable, but the service must also overridesoft_delete_column()to return it (defaultNone⇒ hard delete). With both halves in place,CrudServicedeletes by setting the tombstone and ANDsdeleted_at IS NULLonto every read so a soft-deleted row is invisible — in lists, by-id loads, and relation batches alike. See CRUD for the service side.
The migration
Section titled “The migration”#[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).
Field attribute reference
Section titled “Field attribute reference”| Attribute | Where | Effect |
|---|---|---|
#[expose] | any column | Read-only on the wire. |
#[expose(input(create, update))] | scalar column | Adds to wire + the named input(s). |
#[expose(validate(…))] | input column | Attaches a validator rule to the input field. |
#[expose(complexity = …)] | exposed field / relation | Overrides the GraphQL complexity cost (literal or expression string). |
#[expose] on HasOne/HasMany | relation | Auto-resolved GraphQL field (needs header graphql + service). |
#[expose(via = "…")] | HasMany | Names which of the child’s foreign keys the relation follows — required when it has two to this parent. |
#[wire_enum] / #[wire_enum(graphql)] | a column’s enum type | Emits the wire derives (serde, schemars, async-graphql Enum) routed through the framework. |
Going further
Section titled “Going further”- Entity checklist — every decision one
#[expose]d entity asks of you, one line each. - CRUD —
#[crud]generates the endpoints over the entity’s service. - Response masking — what
unexposed columns and
WireModelDefaultsare for. - SeaORM 2 documentation — the
reference for the columns, relations and migrations under
#[expose].