DEV Community

Cover image for NestJS Ergonomics, Less Boilerplate
Artem M
Artem M

Posted on

NestJS Ergonomics, Less Boilerplate

Recommended reading: Backend composables and One pattern across events — the composable and multi-transport foundation Moost builds on.

NestJS got a lot of things right. Decorator-driven routing, constructor-based DI, interceptors with clear lifecycle phases, pipes that validate before the handler runs. Genuinely good ideas.

But NestJS makes you deal with all of its complexity on day one. Before you write a single handler, you need modules, provider arrays, import/export wiring, scope declarations. The framework does not have a simple mode — it has one mode, and that mode assumes your app is already complex.

What if the framework started simple and only grew more sophisticated when your app actually needed it?

Three files before your first handler

ceremony tax

A basic user CRUD with one guard. Here is the minimum NestJS setup:

// users.module.ts
@Module({
  imports: [AuthModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

// users.controller.ts
@UseGuards(JwtGuard)
@Controller('users')
export class UsersController { ... }

// app.module.ts
@Module({
  imports: [UsersModule, AuthModule, ConfigModule.forRoot()],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Three files before any business logic. And every new feature starts the same way — another module, another provider array, another import/export cycle. The complexity is the same whether you are building a weekend prototype or a production system.

Same app, no ceremony

moost approach

Here is the same app in Moost:

import { Moost, Controller, Injectable } from 'moost'
import { MoostHttp, Get, Post, Param, Body } from '@moostjs/event-http'

@Injectable()
class UsersService {
  async getUser(id: string) {
    return db.users.findById(id)
  }

  async createUser(data: CreateUserDTO) {
    return db.users.create(data)
  }
}

@Controller('users')
class UsersController {
  constructor(private users: UsersService) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    return this.users.getUser(id)
  }

  @Post()
  async createUser(@Body() data: CreateUserDTO) {
    return this.users.createUser(data)
  }
}

const app = new Moost()
app.adapter(new MoostHttp()).listen(3000)
app.registerControllers(UsersController).init()
Enter fullscreen mode Exit fullscreen mode

No modules. No provider arrays. The DI container resolves UsersService from the constructor signature — mark it @Injectable() and it works. Services are singletons by default (add 'FOR_EVENT' when you need per-request instances). This is the entire app — not a stripped-down demo.

A guard is one function, not three files

In NestJS, a guard implements CanActivate, gets registered as a provider in a module, and attached via @UseGuards(). Three concepts, three files worth of wiring, from the first protected route.

In Moost, a guard is a function:

const jwtGuard = defineAuthGuard(
  { bearer: { format: 'JWT' } },
  (transports) => {
    if (!transports.bearer) {
      throw new HttpError(401, 'Missing token')
    }
    verifyJwt(transports.bearer)
  }
)
Enter fullscreen mode Exit fullscreen mode

Attach it to a controller, a method, or the whole app:

@Intercept(jwtGuard)
@Controller('users')
class UsersController { ... }

// or globally
app.applyGlobalInterceptors(jwtGuard)
Enter fullscreen mode Exit fullscreen mode

No separate guard class, no module registration, no APP_GUARD token. One function, one decorator — and it scales to fifty routes without new abstractions.

Interceptors without the middleware puzzle

Most apps start without interceptors. When logging, error formatting, or response transformation becomes necessary, Moost interceptors follow a clear priority chain:

BEFORE_ALL → BEFORE_GUARD → GUARD → AFTER_GUARD → INTERCEPTOR → CATCH_ERROR → AFTER_ALL
Enter fullscreen mode Exit fullscreen mode

Each interceptor hooks into three moments — before the handler, after it, and on error:

@Interceptor()
class LoggingInterceptor {
  @Before()
  onBefore(@Url() url: string) {
    console.log(`→ ${url}`)
  }

  @After()
  onAfter(@Response() response: unknown) {
    console.log(`← ${JSON.stringify(response)}`)
  }

  @OnError()
  onError(@Response() error: Error, @Overtake() reply: TOvertakeFn) {
    console.log(`✗ ${error.message}`)
    reply({ error: error.message })
  }
}
Enter fullscreen mode Exit fullscreen mode

@Overtake() gives a reply function to short-circuit the response — useful for returning a formatted error instead of letting the exception propagate. Skip it and the normal flow continues. No implicit next() chain.

You can also define interceptors as plain functions — defineBeforeInterceptor(), defineAfterInterceptor(), defineErrorInterceptor(). Use whichever style fits the complexity of the task.

Custom decorators scale from one line to full DI

Every real app eventually needs custom decorators — @Roles('admin'), @DisplayName(), @RateLimit(). This is where NestJS's coordination tax becomes most visible.

The canonical example: a @Roles decorator with a guard that checks it. In NestJS, this requires three coordinated pieces — a decorator that writes metadata, a guard class that reads it via Reflector, and a module registration:

// roles.decorator.ts
export const ROLES_KEY = 'roles'
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles)

// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    const roles = this.reflector.getAllAndOverride<string[]>(
      ROLES_KEY, [ctx.getHandler(), ctx.getClass()],
    )
    if (!roles) return true
    const user = ctx.switchToHttp().getRequest().user
    return roles.some(role => user?.roles?.includes(role))
  }
}

// app.module.ts
@Module({
  providers: [{ provide: APP_GUARD, useClass: RolesGuard }],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Three files. The ROLES_KEY string ties decorator and guard together. The Reflector service reads metadata that SetMetadata wrote. The guard has to be registered globally through the module system.

In Moost, the same pattern has two levels — pick the one that matches your complexity.

When the guard needs DI (e.g., a RoleService to check permissions against a database):

type TRolesMeta = { roles: string[] }

const Roles = (...roles: string[]) =>
  getMoostMate<TRolesMeta>().decorate('roles', roles)

@Interceptor(TInterceptorPriority.GUARD)
class RolesGuard {
  constructor(private roleService: RoleService) {}

  @Before()
  async check() {
    const { getMethodMeta, getControllerMeta } = useControllerContext()
    const roles =
      getMethodMeta<TRolesMeta>()?.roles ||
      getControllerMeta<TRolesMeta>()?.roles
    if (!roles) return
    const user = getCurrentUser()
    if (!await this.roleService.hasRole(user, roles)) {
      throw new HttpError(403, 'Insufficient role')
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two pieces instead of three — no module registration, no APP_GUARD token, no Reflector injection. Metadata access is type-safe through TRolesMeta, and getMoostMate().decorate() writes at the right level (method or class) automatically.

When the guard does not need DI — the whole thing collapses into one function:

const RequireRole = (role: string) => {
  const guard = defineBeforeInterceptor(() => {
    const user = getCurrentUser()
    if (!user?.roles.includes(role)) {
      throw new HttpError(403, `Role "${role}" required`)
    }
  }, TInterceptorPriority.GUARD)
  return Intercept(guard)
}
Enter fullscreen mode Exit fullscreen mode

The decorator is the guard. One function, no metadata, no separate class. Use it the same way:

@RequireRole('editor')
@Controller('articles')
class ArticleController {
  @RequireRole('admin')
  @Delete(':id')
  remove() { ... }
}
Enter fullscreen mode Exit fullscreen mode

Custom parameter resolvers follow the same pattern. A @DisplayName() decorator that reads from the body:

const DisplayName = () => Resolve(async () => {
  const { parseBody } = useBody()
  const body = await parseBody<{ fullName?: string; name?: string }>()
  return body.fullName || body.name
}, 'displayName')
Enter fullscreen mode Exit fullscreen mode

NestJS has createParamDecorator with ExecutionContext. Moost has Resolve() with composables. Both work — but Resolve() gives you access to the same composable runtime your handlers use, so custom decorators compose with the rest of the app naturally.

The pattern holds: start with built-in decorators. Write a one-line Resolve() when you need a custom parameter. Write a one-function guard when you need role checks. Grow into class-based interceptors with DI when the logic demands it. Each step is there when you reach for it.

Different config per branch, no module gymnastics

provider tree scoping

This is where NestJS modules earn their keep. Different parts of the app sometimes need different instances of the same provider — an HttpClient pointed at different APIs, a logger with different prefixes. NestJS solves this with dynamic modules: forRoot, forRootAsync, forFeature. Powerful — but you pay for this machinery from the very first module, whether you need branched config or not.

In Moost, the controller tree is the scoping mechanism:

@Provide(HttpClient, () => new HttpClient('https://users-api.internal'))
@Controller('users')
class UsersController {
  constructor(private http: HttpClient) {}
  // this.http → users-api.internal
}

@Provide(HttpClient, () => new HttpClient('https://payments-api.internal'))
@Controller('payments')
class PaymentsController {
  constructor(private http: HttpClient) {}
  // this.http → payments-api.internal
}
Enter fullscreen mode Exit fullscreen mode

@Provide scopes a provider to the controller and its entire subtree. Same class, different instances — no modules needed. And the DI engine is fully async, so wiring an async dependency is just an async factory:

@Provide(HttpClient, async () => {
  const { instantiate } = useControllerContext()
  const config = await instantiate(ConfigService)
  return new HttpClient(config.get('API_URL'))
})
class App extends Moost {}
Enter fullscreen mode Exit fullscreen mode

No forRootAsync, no imports array, no separate inject declaration. instantiate() resolves any injectable class inside the factory with the same DI resolution the rest of the app uses.

Need to swap an entire implementation for a subtree? @Replace handles that:

@Replace(CacheService, RedisCacheService)
@Controller('cached')
class CachedController { ... }
Enter fullscreen mode Exit fullscreen mode

Providers cascade down, any branch can override what it needs. But none of this exists in your codebase until the day your app actually has branched configuration.

Decorators and composables in the same handler

Moost adds decorator-driven structure — controllers, DI, interceptors — on top of Wooks. Every handler runs inside a Wooks event context, which means all composables from the previous articles work here too.

@Controller('reports')
class ReportsController {
  @Get(':id')
  async getReport(@Param('id') id: string) {
    // Decorator-resolved param ↑
    // Composable access ↓
    const { getId } = useEventId()
    const logger = useLogger()

    logger.info(`Report ${id} requested`, { eventId: getId() })
    return generateReport(id)
  }
}
Enter fullscreen mode Exit fullscreen mode

Decorators and composables coexist naturally — most teams start with @Param(), @Body(), @Query() and reach for composables when they need custom request-scoped logic that does not fit a decorator shape.

This also means Moost inherits all Wooks adapters. The same mental model — controllers, DI, interceptors — works across HTTP, CLI, WebSocket, and workflow events, each with its own adapter-specific decorators like @Cli(), @Step(), or @Ws(). More on that in the previous article.

Less ceremony is not less structure

NestJS front-loads its entire conceptual surface: modules, dynamic modules, provider scopes, injection tokens, re-exports, forRoot/forRootAsync/forFeature — all before your app has a reason to need them. A weekend project and a 200-controller monolith start with the same ceremony.

Moost inverts that. Controllers and @Injectable() are enough for most apps. Guards, scoping, @Provide, @Replace, composables — each layer is there when you reach for it, and invisible until you do.

That thinner architecture is not just nicer to read. Moost sits on Wooks, which sits on @prostojs/router and a composable event core. No Express, no Fastify underneath — just Node's HTTP server and a thin routing + context layer. Less framework between the handler and the response means less overhead, less magic to debug, and less surface area for things to go wrong.

The next article digs into what that means when you actually measure it.


Read next: Moost performance — Real SaaS benchmark: 21 routes, cookies, auth guards, body parsing. How does less framework translate to throughput?

Top comments (0)