The Problem That Won't Go Away
A few days ago, a new issue appeared on the NestJS repository: a developer needed to add an MCP server to their existing application, but their global APP_GUARD was breaking it. The guard covered everything — including routes they didn't own and couldn't decorate with @Public().
Nothing unusual, right? Except that community member micalevisk showed up with an eye-opening summary. The same request has been filed roughly every 18 months since 2017:
#144 (2017) → #964, #710 (2018) → #1488, #1521 (2019) → #3856 (2020) → #6138, #7193, #8011 (2021) → #10572 (2022) → #12772 (2023) → #14352 (2024) → #15284, #15795, #15852 (2025) → #17697, #17962 (2026).
That's over 17 separate issues spanning 9 years. All closed. No PR has ever attempted an implementation.
The official stance, recorded in #6138, was: "Conceptually, there's no such thing as module-level metadata in NestJS."
But as micalevisk pointed out, NestJS has since shipped at least four features that behave exactly like module-level metadata:
MODULE_PATHRouterModule-
GraphQLModule'sinclude?: Function[] -
GraphQLModule'sfieldResolverEnhancersoption
The philosophical objection seems to have already been contradicted by the framework's own evolution.
Why This Actually Hurts
This isn't a nice-to-have. There are cases where no workaround exists, because every sanctioned escape hatch (@Public(), Reflector, @SkipThrottle(), req.url checks) requires editing the source of the route being exempted:
-
Third-party modules mounted in your
AppModule— like the MCP case above. You can't decorate controllers you don't own. - Plugin architectures where plugins ship their own controllers.
-
A global
CacheInterceptorbreaking GraphQL (nestjs/graphql#443). - A global throttler guard crashing GraphQL subscriptions (nestjs/throttler#1110, open since 2022).
- Mixed-transport applications — a GraphQL API and an MQTT service in one process, where the JWT guard leaks onto MQTT (#10572).
The workaround posted by the issue author is telling — a 50+ line abstract class that crawls ModulesContainer internals, walks the module tree, collects controller metatypes, and filters in canActivate(). It works, but it depends on undocumented internals and shouldn't be necessary.
What NestJS Users Want
The request is simple: introduce MODULE_GUARD (and MODULE_INTERCEPTOR, MODULE_PIPE, MODULE_FILTER) tokens that scope an enhancer to the current module rather than the entire application. Keep APP_GUARD as-is. No breaking changes. Just a new level of granularity.
In NestJS, you currently have two extremes:
-
APP_GUARD— applies to every route in the entire application -
@UseGuards()on a controller — applies only to that specific controller
There's nothing in between. You can't say "apply this guard to all routes in this module and its imports."
How Holu Solves This Out of the Box
In Holu, this isn't a feature request — it's the default behavior. The framework's modular architecture was designed from day one to give you fine-grained control over guard scoping.
Here's how you set a guard on an imported module:
import { restModule } from '@holu/rest';
import { OtherModule } from '../other/other.module.js';
import { AuthModule } from '../auth/auth.module.js';
import { AuthGuard } from '../auth/auth.guard.js';
@restModule({
imports: [
AuthModule,
{ module: OtherModule, path: '', guards: [AuthGuard] },
],
})
export class SomeModule {}
That's it. AuthGuard is automatically added to every route in OtherModule — and only to routes in OtherModule. No magic tokens. No internal crawling. No workarounds. It's a first-class feature of the module import syntax.
Note that the providers for the specified guard must be available in SomeModule — which is why it imports the AuthModule.
What This Means in Practice
Let's go back to the original NestJS issue — the MCP case. The developer wanted their AuthGuard to protect all REST controllers, but not the MCP server module.
In Holu, this would look like:
import { restModule } from '@holu/rest';
import { AuthModule } from '../auth/auth.module.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { ApiModule } from '../api/api.module.js';
import { MCPModule } from '../mcp/mcp.module.js';
@restModule({
imports: [
AuthModule,
{ module: ApiModule, path: 'api', guards: [AuthGuard] },
MCPModule, // no guard — MCP is unprotected, as intended
],
})
export class AppModule {}
Clean. Explicit. Declarative. You can see at a glance which modules are protected and which aren't.
Guards with Parameters Work Too
Holu also supports parameterized guards with full type safety via createGuardHelper():
import { createGuardHelper } from '@holu/rest';
import { Permission } from './types.js';
import { PermissionsGuard } from './permissions-guard.js';
export const requirePermissions = createGuardHelper<Permission>(PermissionsGuard);
Then in a controller:
@controller()
export class SomeController {
@route('GET', 'administration', [requirePermissions(Permission.canActivateAdministration)])
helloAdmin(ctx: RequestContext) {
ctx.send('some secret');
}
}
Or at the module level:
@restModule({
imports: [
{ module: AdminModule, path: 'admin', guards: [requirePermissions(Permission.canActivateAdministration)] },
],
})
export class SomeModule {}
The Bigger Design Issue
The NestJS APP_GUARD pattern is a symptom of a deeper architectural choice: enhancers are either global (via injection tokens) or local (via decorators). The module — which is supposed to be the unit of composition — has no say in it.
Holu's approach treats modules as first-class boundaries. When you import a module, you can specify:
- The path prefix for its routes
- The guards that protect its routes
This is possible because Holu's module system was designed to be truly modular — each module import is a configuration point, not just a dependency declaration.
Conclusion
For 9 years, NestJS users have been asking for module-scoped guards. The community has filed issue after issue, built workarounds on top of internal APIs, and written thousands of lines of if (context.getClass() === SomeController) checks. While the framework maintainers stick to their architectural boundaries, developers are left building complex workarounds for a fundamental composition problem.
If you're building a Node.js application where modularity matters — where you need to compose modules with different security policies, where you integrate third-party modules that shouldn't be affected by your global guards — consider giving Holu a look. This problem was solved from day one.
Holu is a Node.js web framework powered by DI, TypeScript, and true modularity. Check it out on GitHub.
Top comments (0)