Skip to content

Expose over HTTP

You write the service that owns the entity, the controller that mounts the CRUD routes, and the HTTP module that ties them together. By the end of this page, the boot log shows five CRUD routes mounted from one empty #[crud] impl. The first successful curl lands on the next page, Persist through Postgres, once Postgres is wired.

A service is the entity’s single DB gateway. Every read and write goes through Repo<Posts>; controllers, resolvers, gateways never touch SeaORM directly. The HTTP layer in this section drives a service that implements CrudService — that trait is what the #[crud(...)] macro on the controller expands against.

The service crate here is nest-rs-seaorm — it provides CrudService, Repo, ServiceError, and the SeaORM integration. SeaORM stays the source of truth for the column types and the active-model semantics.

crates/features/src/posts/service.rs
use nest_rs_core::injectable;
use nest_rs_seaorm::{Creatable, CrudService, Deletable, Updatable};
use super::entity::{CreatePost, Entity as Posts, 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 {}

#[injectable] marks the struct as a provider — the container builds it once and shares it as Arc<PostsService>. CrudService carries the read half — it names the entity, and every resource implements it. Writes are opt-in traits: Creatable (type Create), Updatable (type Update), and Deletable — a resource implements each only when it genuinely offers that operation. A read-only projection implements CrudService alone and declares no write trait. No custom methods yet: the read half plus these three write impls give a bare CRUD feature its full surface.

Repo::<Posts>::conn() reaches the ambient executor — the pool on safe routes, the transaction on mutating routes. You don’t pass it in; the data layer installs it before the handler runs (Persist through Postgres makes that concrete).

Register the service in the port module:

crates/features/src/posts/module.rs
use nest_rs_core::module;
use super::service::PostsService;
#[module(providers = [PostsService])]
pub struct PostsModule;

A controller is a struct with #[controller(path = ...)] and a #[crud(...)] impl block that declares the entity. The macro generates every CRUD verb — list, page, create, get, update, delete — from the service’s trait impls: the read verbs from CrudService, and each mutating verb only if the matching write trait is implemented. Naming create = ... without a Creatable impl is a build break, not a silent no-op — a forgotten write path fails to compile. You don’t write handler bodies unless you need domain logic the trait doesn’t cover.

crates/features/src/posts/http/controller.rs
use std::sync::Arc;
use nest_rs_http::{controller, crud};
use crate::authn::AuthnGuard;
use crate::authz::AuthzGuard;
use crate::posts::{CreatePost, Entity as PostEntity, Post, PostsService, UpdatePost};
#[controller(path = "/posts")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct PostsController {
#[inject]
svc: Arc<PostsService>,
}
#[crud(
service = svc,
entity = PostEntity,
output = Post,
create = CreatePost,
update = UpdatePost,
)]
impl PostsController {}

A DB-backed controller needs the two guards, and that is a property of the data layer rather than a policy choice. Repo filters every read by the caller’s ambient Ability; nothing but AuthzGuard installs one, and without it every row is filtered out and every generated op rejects the request before it reaches your service. There is no “skip auth for now” configuration — a public read of a table is not something the 1.0 data layer expresses.

AuthnGuard and AuthzGuard are your types, not framework ones: the framework is generic over the principal and the policy, so each workspace writes them once. Generate them:

Terminal window
nestrs g auth

That writes crates/features/src/{identity,authn,authz}/, adds the two modules to the app, and appends a development NESTRS_AUTHN__SECRET to .env. Then grant the ability — the policy starts empty, so Post needs a rule:

crates/features/src/authz/ability.rs
use nest_rs_authz::{AbilityBuilder, AbilityFactory, Action};
use crate::posts as post;
impl AbilityFactory for AppAbility {
type Actor = Claims;
fn define(&self, _actor: &Claims, ab: &mut AbilityBuilder) {
ab.can(Action::Manage, post::Entity);
}
}

Manage is every action on Post, unconditionally — the smallest rule that lets the tutorial move. Authenticate and authorize narrows it down to what a given caller may actually touch.

The feature’s HTTP adapter is a separate module. It imports the port (PostsModule) and AuthzHttpModule, which is what makes the ability guard reachable — omit it and the access graph fails the boot by name.

crates/features/src/posts/http/module.rs
use nest_rs_core::module;
use super::controller::PostsController;
use crate::authz::AuthzHttpModule;
use crate::posts::PostsModule;
#[module(
imports = [PostsModule, AuthzHttpModule],
providers = [PostsController],
)]
pub struct PostsHttpModule;
crates/features/src/posts/http/mod.rs
mod controller;
mod module;
pub use controller::PostsController;
pub use module::PostsHttpModule;

Add the adapter to the feature root:

crates/features/src/posts/mod.rs
mod entity;
mod module;
mod service;
pub mod http;
pub use entity::*;
pub use module::PostsModule;
pub use service::PostsService;
pub use http::{PostsController, PostsHttpModule};

The app root imports the feature’s HTTP adapter beside the greeting one the scaffold wired on page 1.

apps/blog/src/module.rs
use nest_rs_core::module;
use nest_rs_http::{HttpConfig, HttpModule};
use features::authn::AuthnModule;
use features::authz::AuthzHttpModule;
use features::blog::BlogHttpModule;
use features::posts::PostsHttpModule;
#[module(imports = [
HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() }),
AuthnModule,
AuthzHttpModule,
BlogHttpModule,
PostsHttpModule,
])]
pub struct BlogModule;

BlogHttpModule is the scaffold’s greeting on /. Drop the import and delete crates/features/src/blog/ whenever you want — it exists so page 1 had something to prove.

PostsHttpModule already imports AuthzHttpModule transitively, so listing both here changes nothing at boot. It changes what the file tells you: an app’s root module is the inventory of the concerns it serves, and auth is one of them.

Add features to apps/blog/Cargo.toml:

apps/blog/Cargo.toml
[dependencies]
nest-rs-core.workspace = true
features.workspace = true
nest-rs-http.workspace = true
anyhow.workspace = true
tokio.workspace = true

The port 3005 is what the reference workspace pins — yours may differ if other apps already occupy it when you ran nestrs new blog.

Boot the binary and read the route table from the boot log — the nest_rs::routes target prints every mount on startup.

Terminal window
$ nestrs run dev blog
DEBUG nest_rs::http: transport listening addr=0.0.0.0:3005 tls=false
Terminal window
INFO nest_rs::routes: mounted route controller=PostsController method=GET path=/posts
INFO nest_rs::routes: mounted route controller=PostsController method=POST path=/posts
INFO nest_rs::routes: mounted route controller=PostsController method=GET path=/posts/:id
INFO nest_rs::routes: mounted route controller=PostsController method=PATCH path=/posts/:id
INFO nest_rs::routes: mounted route controller=PostsController method=DELETE path=/posts/:id

Five routes mounted from one #[crud] block — the full CRUD surface, before you wrote a single handler body. The shape is right; the live call lands on the next page.

The guards run first, so an anonymous call stops at the door — the route table is mounted, the data layer is not, and neither matters yet:

Terminal window
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3005/posts
401
Terminal window
WARN nest_rs::authn: authentication failed reason="missing_credentials"
WARN nest_rs::layers: guard denied the request status=401

Drop #[use_guards(...)] from the controller and the answer becomes a 500 instead, because #[crud] still emits the class gate and nothing installed an ability for it to read:

Terminal window
ERROR nest_rs::authz: missing request Ability — route is authorized but no ability guard ran
action=Read subject="features::posts::entity::Entity"
hint="bind the ability guard (#[use_guards(AuthnGuard, AuthzGuard)]) …"

That log line is the difference between a five-minute fix and an afternoon: grep nest_rs::authz whenever a guarded route answers 500. Persist through Postgres plugs DatabaseModule in, and the same curl — with a token — returns a post.

  • A PostsService that owns the entity through Repo<Posts>.
  • A PostsController with the full CRUD surface #[crud] generated, behind AuthnGuard + AuthzGuard.
  • An AppAbility granting Manage on Post.
  • A PostsHttpModule mounted in the app — the boot log shows the routes.

Built by YV17labs