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, curl returns a fresh post from POST /posts and the same post from GET /posts/:id — once Postgres is wired on page 6.

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::CrudService;
use super::entity::{CreatePostDto, Entity as Posts, UpdatePostDto};
#[injectable]
#[derive(Default)]
pub struct PostsService;
impl CrudService for PostsService {
type Entity = Posts;
type Create = CreatePostDto;
type Update = UpdatePostDto;
}

#[injectable] marks the struct as a provider — the container builds it once and shares it as Arc<PostsService>. The CrudService impl declares the entity and the inputs the controller needs. No custom methods yet: the generated create / find_by_id / list paths on the trait are enough for a bare CRUD feature.

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 (page 6 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 CrudService impl. 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::posts::{CreatePostDto, Entity as PostEntity, Post, PostsService, UpdatePostDto};
#[controller(path = "/posts")]
pub struct PostsController {
#[inject]
svc: Arc<PostsService>,
}
#[crud(
service = svc,
entity = PostEntity,
output = Post,
create = CreatePostDto,
update = UpdatePostDto,
)]
impl PostsController {}

No guards on the struct — blog is a public API for the tutorial. When you copy users into api, the same shape picks up #[use_guards(...)] and hand-written create / get methods that read the caller’s org from a JWT.

The feature’s HTTP adapter is a separate module. It imports the port (PostsModule) and lists the controller.

crates/features/src/posts/http/module.rs
use nest_rs_core::module;
use super::controller::PostsController;
use crate::posts::PostsModule;
#[module(
imports = [PostsModule],
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 now imports the feature’s HTTP adapter — that’s the only edit apps/blog/ needs beyond the scaffold from page 1.

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

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
nest-rs-opentelemetry = { workspace = true, features = ["http"] }
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.

  1. Boot the binary.

    Terminal window
    $ nestrs run dev blog
    DEBUG nest_rs_http::transport: http transport listening addr=0.0.0.0:3005
  2. The controller and the service compile, but you haven’t wired a database yet — Repo::<Posts>::conn() returns an error. Page 6 plugs DatabaseModule in. For now, confirm the routes mounted.

    Terminal window
    $ curl -i http://localhost:3005/posts
    HTTP/1.1 500 Internal Server Error

The route table has the verbs (GET /posts, GET /posts/:id, POST /posts, PATCH /posts/:id, DELETE /posts/:id); the 500 is honest — there’s no ambient DB to reach yet. The shape is right.

Confirm the route table from the boot log: the nest_rs::routes target prints every mount on startup.

Terminal window
INFO nest_rs::routes: GET /posts
INFO nest_rs::routes: POST /posts
INFO nest_rs::routes: GET /posts/:id
INFO nest_rs::routes: PATCH /posts/:id
INFO nest_rs::routes: DELETE /posts/:id
  • A PostsService that owns the entity through Repo<Posts>.
  • A PostsController with the full CRUD surface #[crud] generated.
  • A PostsHttpModule mounted in the app — the boot log shows the routes.

Next: validate inputs at the boundary →