Declare the entity
You declare the shape of a post once — a SeaORM model decorated with
#[expose] — and the macro emits the wire DTO, the create/update
inputs, the validator wiring, and the JSON schema the HTTP transport
reaches for. By the end of this page, nestrs run check passes on a new
posts feature crate path.
Move into the features crate
Section titled “Move into the features crate”The reference feature lives at crates/features/src/posts/. Create the
same folder so the tutorial can extend it in place.
Directorycrates/features/src/posts/
- mod.rs
- module.rs
- entity.rs
- service.rs
The posts module ships under the workspace’s features crate. Its
dependencies ride versions pinned in the workspace root, so both manifests
need an entry. Add the pins first:
[workspace.dependencies]nest-rs-authz = { version = "1.1", features = ["http"] }nest-rs-resource = "1.1"schemars = { version = "1", features = ["uuid1"] }serde = { version = "1", features = ["derive"] }uuid = { version = "1", features = ["v7", "serde"] }validator = { version = "0.20", features = ["derive"] }nest-rs-seaorm and sea-orm are already there — the migrations crate the
workspace ships uses them. Then the feature crate:
[dependencies]nest-rs-authz.workspace = truenest-rs-resource.workspace = truenest-rs-seaorm.workspace = trueschemars.workspace = truesea-orm.workspace = trueserde.workspace = trueuuid.workspace = truevalidator.workspace = trueTwo of those are call-site dependencies of the decorators rather than of your
own code: #[expose] derives schemars::JsonSchema, and the #[crud] you add
on the HTTP page expands to nest_rs_authz types. A derive
resolves against your crate’s prelude, so the framework cannot supply them for
you. nestrs g resource posts writes both manifests for you.
The first cut
Section titled “The first cut”A post has an id, a title, and a body. SeaORM owns the column types;
#[expose] decides which columns cross the wire and which inputs the
HTTP layer accepts. Exposure is opt-in — a field surfaces only when it
carries #[expose]. No org column, no auth hooks — the tutorial keeps
the domain small so you can focus on the entity → service → controller
chain.
The crate this page builds on top of is
nest-rs-resource —
it provides the #[expose] macro and the WireModelDefaults trait the
masking layer reads when a feature later adds authorization. SeaORM
itself is the source of truth for entities, columns, and migrations.
use nest_rs_resource::expose;use sea_orm::entity::prelude::*;use serde::{Deserialize, Serialize};
#[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 {}What #[expose] decides
Section titled “What #[expose] decides”The decorator translates one declaration into every wire surface:
| Generated | What it is | Where it surfaces |
|---|---|---|
Post | The wire DTO — every #[expose]d column, no SeaORM internals and nothing left unexposed | HTTP responses, OpenAPI schema |
CreatePost | A struct holding fields marked input(create) | POST /posts body |
UpdatePost | A struct holding fields marked input(update) | PATCH /posts/:id body |
service = super::service::PostsService tells the macro which service
owns the entity. The path is required as soon as the entity declares an
exposed relation or another feature needs the PK loader — posts has
neither yet, but the attribute is already in place for when you add one.
Read the field-level attributes
Section titled “Read the field-level attributes”Two patterns cover this entity; a third shows up when you copy
users in api.
| Attribute | Effect |
|---|---|
#[expose] | Bare — the field is read-only on the wire (appears in Post), but not in any input |
#[expose(input(create, update), validate(length(min = 1)))] | Field appears in Post (input implies read) and in CreatePost and UpdatePost, with the validator rule wired |
#[expose(input(create))] | Field appears in Post and only in CreatePost — common for fields that can be set but not edited |
(no #[expose]) | Field is hidden from Post and from every input — the masker fills it back in from defaults so Ability::mask sees the whole row |
Leaving a field unexposed matters once a feature carries server-side
columns — password hashes, tenant ids set from a JWT. Because exposure
is opt-in, those columns stay off the wire by default;
api is where unexposed columns and masking become
load-bearing.
The mod entry
Section titled “The mod entry”The feature root re-exports what other modules import. Keep it small — this is the public surface.
mod entity;mod module;
pub use entity::*;pub use module::PostsModule;PostsService doesn’t exist yet; that’s the next page. For now, keep
module.rs and service.rs stubbed so the crate compiles.
use nest_rs_core::module;
#[module]pub struct PostsModule;// Filled in on the next page.Wire the feature into the parent crate’s lib.rs:
pub mod posts;$ nestrs run check Checking features v0.1.0 Finished `dev` profile [unoptimized] target(s) in 1.42sThe macro expanded, the entity compiled, and Post / CreatePost
/ UpdatePost are now usable from the rest of the crate.
What you have now
Section titled “What you have now”- A
PostSeaORM entity withid,title,body. - An auto-generated
Postwire DTO andCreatePost/UpdatePostinput structs. - A
PostsModuleshell ready to declare its first provider.
Going further
Section titled “Going further”- See the security payoff — the next step: run the reference
apiand watch access control enforced before you build it. - Entities — the reference page for
#[expose]columns and inputs.
Built by YV17labs