Coming from NestJS
Map your NestJS reflexes onto NestRS decorators, and see what runs differently underneath.
If NestJS shaped how you think about backends, most of NestRS will read like home. You still write a module, a service, a thin controller, and let decorators carry the wiring. This page maps the vocabulary you already know onto its NestRS equivalent, then shows where the machine underneath diverges.
#[controller(path = "/users")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}
#[routes]impl UsersController { #[get("/:id")] async fn get(&self, user: Bind<Read, UsersService>) -> Json<User> { Json(User::from(&*user)) }}A @Controller('users') with an injected service and a @Get(':id') — the
shape survives the port to Rust. The decorators changed spelling, not meaning.
The equivalence table
Section titled “The equivalence table”Each row is a reflex you already have on the left and its NestRS spelling on the right. The layer vocabulary — module, provider, controller, guard, resolver — carries across intact.
| NestJS | NestRS |
|---|---|
@Module({ imports, providers }) | #[module(imports = [...], providers = [...])] |
@Module({ controllers: [...] }) | the same providers list — there is no controllers key |
@Injectable() | #[injectable] |
@Controller('users') | #[controller(path = "/users")] |
@Get() / @Post() methods | #[get("/")] / #[post("/")] in a #[routes] impl |
CanActivate guard | Guard trait (check_http) + #[use_guards(...)] |
DTO + class-validator | entity #[expose] + validate(...) (the validator crate) |
@Param('id') / @Query() / @Body() | the parameter type: Path<Uuid>, Query<F>, Json<T> |
@ApiProperty() / @Field() per property | one #[expose] on the column, schemas derived |
@Inject('TOKEN') provider token | inject Arc<dyn Trait>, provider listed as dyn Trait |
@UseGuards(...) | #[use_guards(...)] |
@UseInterceptors(...) | #[use_interceptors(...)] |
@UseFilters(...) (binds @Catch(...) classes) | #[use_exception_filters(...)] — #[use_filters(...)] is the unconditional mapper, not the typed catch |
@Resolver() operation | #[operations] + per-op #[authorize(Action, Entity)] / #[public] |
OnModuleInit interface | #[hooks] impl + #[on_module_init] on the method |
ConfigModule.forRoot() + registerAs | #[config(namespace = "…")] + NESTRS_<NS>__<KEY> |
Test.createTestingModule(...) | TestApp booting the real AppModule |
nest g resource users | nestrs g resource users |
| Runtime DI resolution | access graph checked at boot, with a named error |
The rows are close, but four of them carry a design change worth reading in full: what a module lists, provider visibility, when wiring is verified, and where configuration comes from.
A controller is a provider
Section titled “A controller is a provider”@Module({ controllers: [...], providers: [...] }) keeps two lists because a
NestJS controller is a different kind of citizen — not injectable, never
exported. NestRS keeps one. #[module] accepts exactly two keys, imports and
providers, and an unknown key is a compile error naming the two.
use nest_rs::core::module;
use super::controller::UsersController;use crate::authz::AuthzModule;use crate::users::UsersModule;
#[module( imports = [UsersModule, AuthzModule], providers = [UsersController],)]pub struct UsersHttpModule;A controller, a resolver, a gateway, a queue processor and an MCP tool are all
providers: built by the same container, holding the same #[inject] fields,
resolved by the same access-graph check. What makes one an HTTP route table is
#[controller] + #[routes] — the decorator carries the transport, so the
module list has nothing left to say about it.
The payoff is per-binary composition. A transport mounts only when its module is
reachable from the running app’s root, so one feature crate serves an API that
imports UsersHttpModule and UsersGraphqlModule and a worker that imports
neither. Dropping the import unmounts the routes, and there is no second list to
keep in sync with the first.
No exports array
Section titled “No exports array”In NestJS a provider is private to its module until you add it to exports,
and a consumer imports the whole module to reach it. NestRS has no per-module
export list. A provider becomes shareable by exposing a pub trait and binding
it in the module’s providers as SomeProvider as dyn Trait; a consumer then
injects Arc<dyn Trait>.
Visibility is Rust’s job. The container is flat, and the orphan and coherence
rules already decide what a crate can name. Adding an exports list on top of
that would be a second, redundant gate, so there is not one.
Wiring is checked at boot, not on the first request
Section titled “Wiring is checked at boot, not on the first request”NestJS resolves the dependency graph as the app instantiates, and a bad wire
often surfaces as a Cannot resolve dependency the first time a route runs.
NestRS records every module’s imports and every provider’s dependencies at
compile time. At startup, App::build() walks that graph.
A provider that injects something its module cannot reach fails startup with an error naming the missing dependency and the fix — never a runtime resolution error minutes after deploy. The reflex to test-request an endpoint just to confirm it wired up does not carry over; if it boots, it’s wired — and it boots in milliseconds, so you find out immediately.
Every config field has an environment variable
Section titled “Every config field has an environment variable”In NestJS the two configuration paths rarely line up: some knobs live in a
forRoot literal in code, others behind an untyped
configService.get('SOME_KEY'), and which is which is a per-module habit.
NestRS makes it a framework rule instead. Every field of every nest-rs-*
module config is reachable both ways:
- Pinned in code —
Module::for_root(MyConfig { .. }), type-checked by the compiler. NESTRS_<NAMESPACE>__<KEY>— one explicit line per variable infrom_env, so the file lists exactly which variables exist.
The two compose per field, not per struct: the pinned struct is the base
the environment overlays. Pinning port in HttpConfig leaves
NESTRS_HTTP__COMPRESSION free to move, and neither path erases the other.
“The environment” here is the real process environment only. A for_root
pin outranks a .env file committed beside the code; it does not outrank a
variable actually present in the environment at startup — the full chain is on
Configuration.
That is what pays off in a container. The image ships one binary with sane pins. The deployment then moves any
knob through the env block of a Compose file or a Kubernetes Deployment,
or through a Secret: no rebuild, no config file baked into a layer, no
override mounted at a path the process has to go find. A
malformed value is boot-fatal and names the variable, so a bad ConfigMap fails
the readiness probe instead of surfacing as a mystery three requests later.
Security is structural, not per-decorator discipline
Section titled “Security is structural, not per-decorator discipline”In NestJS, authorization is a habit: you remember to add @UseGuards and a
policy check on each route, and a forgotten one is a silent hole. NestRS moves
the guarantee into composition. Once a feature imports the database and authz
modules, every read through the data layer is filtered, every mutating write is
gated, and every response body is masked.
A feature does not opt in to authz per handler. It opts out by not importing
the modules. You still bind guards explicitly with #[use_guards(...)], and
every posture stays greppable as one #[authorize] or #[public] site — but
forgetting the filter on a query is a category error the framework prevents,
not a review catch.
Globals are declared twice on purpose
Section titled “Globals are declared twice on purpose”APP_GUARD binds a NestJS guard once, in a module, for the whole app — and a
controller has no way to say it depends on that binding. NestRS binds globals in
main with App::builder().use_guards_global([...]), but the convention is to
also declare the guard on the controller, resolver or gateway that needs it.
That is not double work: the Layer System dedups every layer by TypeId across
scopes, so a guard listed twice resolves once and runs once per request.
The redundancy buys portability. A feature crate travels between binaries, and the app that forgets a global would otherwise leave a controller silently unprotected. Declared on the provider, the requirement travels with the code — and a binary with no global pool at all fails boot rather than serving open routes.
There is no forwardRef
Section titled “There is no forwardRef”forwardRef(() => OtherService) exists because JavaScript modules let two files
import each other and resolve the tie at runtime — NestJS then needs a way to
defer one side of a cycle it cannot see until it runs. Rust does not offer the
tie in the first place. Cargo refuses a dependency cycle between crates
outright, so the layering you drew is the layering you get.
Inside a module, two providers waiting on each other fail the register phase by name, and the panic carries the remedy:
module `PostsModule`: dependency cycle among provider(s) ["PostsService", "UsersService"] — each waits on another provider in the same module; break it by injecting `Arc<dyn Trait>` instead of the concrete typeThat remedy is the point. A cycle is a design signal — two things that should
meet at an interface are reaching for each other’s concrete type. Injecting
Arc<dyn Trait> puts the seam back, and the cycle disappears instead of being
deferred. There is nothing to wrap in a thunk, and no runtime resolution order
to reason about.
Other things you will reach for and not find
Section titled “Other things you will reach for and not find”Each is deliberate: the concept exists in NestJS to work around something Rust does not do.
| NestJS | Why NestRS has none |
|---|---|
configure(consumer) middleware | The word covers two shapes. Gating is a Guard, wrapping is an Interceptor, body normalization is a Pipe, CORS and security headers are HttpConfig. Middleware maps every role to its home |
@Global() | The import list is the declaration, and it is what the boot-time access-graph check reads. A module reachable everywhere without saying so is exactly the wire that check exists to catch |
app.enableShutdownHooks() | Never opt-in. #[on_module_destroy] and #[on_application_shutdown] always run, best-effort, so one failing cleanup doesn’t skip the rest |
@Injectable() on everything | #[injectable] marks a provider the container builds; a controller, resolver or gateway is already one through its own decorator |
The parameter type is the decorator
Section titled “The parameter type is the decorator”@Param('id'), @Query(), @Body(), @Req() have no counterpart, because the
job they do is already done by the parameter’s type. A handler declares
Path<Uuid>, Query<Filters>, Json<CreatePost> or Valid<Json<CreatePost>>,
and the extraction, the parse and the 400 on malformed input come from that
type. The same applies to the request-scoped Scoped<T>, the guard-attached
Ctx<T>, and Bind<Action, Service> — Extractors is the
full list.
Interceptors follow the same move. There is no RxJS: intercept is an
async fn receiving Next<'_>, and “after the handler” is the code you write
after next.run(req).await — no .pipe(map(...)), no stream to remember to
subscribe.
One declaration feeds every schema
Section titled “One declaration feeds every schema”A NestJS field is declared several times: on the entity, on the DTO, again with
@ApiProperty() for Swagger, again with @Field() for GraphQL. Each copy is a
chance to drift. NestRS declares it once — #[expose] on the SeaORM column —
and derives the wire type, the create/update inputs, the JSON Schema behind
GET /api-json, and the GraphQL object from that one site.
A column with no #[expose] reaches no transport at all, so the default is
closed: adding a column to a table never widens the API by accident.
No runtime reflection, because nothing was erased
Section titled “No runtime reflection, because nothing was erased”NestJS reads decorator metadata through reflect-metadata at runtime — the
crutch exists because TypeScript types vanish at compile time, so the framework
has to recover them from a side channel, and an interface can’t be injected
without a hand-written token. NestRS decorators are attribute macros: they
expand to plain Rust during compilation, and the types they read are the ones
the compiler checks. There is no metadata registry to query at runtime, and no
.env-driven reflection deciding what a decorator means — the environment
moves values, every one of them, and never shape. What the macro emitted is what
runs.
What ships is one executable. cargo build --release produces a binary you
copy into a distroless image — no node_modules to install, no runtime to
pin alongside it, no npm ci in the Dockerfile. The monorepo habits do carry
over: a Cargo workspace is the layout you already run, apps/* next to shared
crates, one lockfile at the root, local packages wired by path.
What the port changes
Section titled “What the port changes”The same slice — a users controller with an injected service and one guarded route. The NestJS sketch:
@Controller('users')@UseGuards(AuthnGuard, AuthzGuard)export class UsersController { constructor(private readonly svc: UsersService) {}
@Get(':id') async get(@Param('id') id: string): Promise<User> { return this.svc.findInOrg(id); }}The NestRS equivalent is the snippet that opened this page. The guard binding,
the injected service and the route decorator each have a direct counterpart —
the one line with no counterpart is the parameter. @Param('id') id: string
hands you a string to look up; Bind<Read, UsersService> hands you the row.
The org scoping is not an argument you thread through by hand. The guard
established the principal, and Bind resolves the id to a row the caller is
authorized to read — outside the caller’s org is a 403, absent is a 404.
Going further
Section titled “Going further”- Why NestRS — the thesis and the six structural properties behind it.
- Fundamentals — modules, providers, guards, pipes, filters.
- Providers — the access graph, checked at boot.
- Getting started — scaffold and run your first app.