Skip to content

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.

crates/features/src/users/http/controller.rs
#[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.

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.

NestJSNestRS
@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 guardGuard trait (check_http) + #[use_guards(...)]
DTO + class-validatorentity #[expose] + validate(...) (the validator crate)
@Param('id') / @Query() / @Body()the parameter type: Path<Uuid>, Query<F>, Json<T>
@ApiProperty() / @Field() per propertyone #[expose] on the column, schemas derived
@Inject('TOKEN') provider tokeninject 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 usersnestrs g resource users
Runtime DI resolutionaccess 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.

@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.

crates/features/src/users/http/module.rs (from the demo)
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.

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:

  1. Pinned in codeModule::for_root(MyConfig { .. }), type-checked by the compiler.
  2. NESTRS_<NAMESPACE>__<KEY> — one explicit line per variable in from_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.

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.

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 type

That 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.

NestJSWhy NestRS has none
configure(consumer) middlewareThe 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

@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.

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.

The same slice — a users controller with an injected service and one guarded route. The NestJS sketch:

users.controller.ts
@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.

  • 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.