The Obvious, But Flawed, Approach: Guard Clauses

The most straightforward way to enforce subscription plan limits in a NestJS application is to add conditional checks directly within controller methods. For instance, a controller responsible for creating new courses might include a check like this:

if (user.plan === 'BASIC' && courseCount >= 3) {
  throw new ForbiddenException('Upgrade to create more courses');
}

While this approach works and is easy to implement initially, it quickly leads to code duplication and makes controllers bloated and hard to maintain. Imagine having to add this same check, or variations of it, across dozens of endpoints that create or modify resources. It violates the DRY (Don't Repeat Yourself) principle and makes refactoring a nightmare. If you need to change the limits or add new plan tiers, you’d have to hunt down and modify every single instance of these checks. This is like trying to repair a leaky pipe by patching every inch of it instead of replacing the faulty section.

Introducing NestJS Interceptors for Centralized Logic

NestJS offers powerful tools for abstracting cross-cutting concerns, and Interceptors are a prime candidate for handling authorization and plan enforcement. Interceptors provide a way to wrap around route handlers, allowing you to intercept and manipulate request/response cycles. This is ideal for enforcing rules that apply to multiple routes without cluttering the controllers themselves.

A common pattern is to create a custom decorator that, when applied to a route handler, triggers an interceptor. This interceptor can then inspect the user's plan and the requested action to determine if the limit has been reached.

Let's break down how this can be implemented:

1. The Custom Decorator

First, define a custom decorator. This decorator will carry metadata that the interceptor can use. For example, it could specify the resource type and the maximum allowed count for a given plan.

import { SetMetadata } from '@nestjs/common';

export const RequiresPlanLimit = (resourceType: string, limit: number) => SetMetadata('requiresPlanLimit', { resourceType, limit });

Here, `RequiresPlanLimit` is a factory function that returns the actual decorator. It uses `SetMetadata` to attach a key (`'requiresPlanLimit'`) and its value (an object containing the resource type and limit) to the route handler.

2. The Interceptor

Next, create an interceptor that reads this metadata and performs the enforcement logic. This interceptor will be applied globally or to specific routes using the `@UseInterceptors()` decorator.

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  Reflector,
  ForbiddenException
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';

@Injectable()
export class PlanLimitInterceptor implements NestInterceptor {
  constructor(private reflector: Reflector) {}

  async intercept(context: ExecutionContext, next: { call: () => Observable}): Promise|Observable {
    const metadata = this.reflector.get('requiresPlanLimit', context.getHandler());

    if (!metadata) {
      return next.call();
    }

    const { resourceType, limit } = metadata;
    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user) {
      throw new ForbiddenException('User not authenticated');
    }

    // This part requires fetching the current count of resources for the user.
    // This would typically involve calling a service. For simplicity, we'll use a placeholder.
    const currentCount = await this.getResourceCount(user.id, resourceType);

    if (user.plan === 'BASIC' && currentCount >= limit) {
      throw new ForbiddenException(`Upgrade to create more ${resourceType}s`);
    }

    return next.call().pipe(
      tap(() => { // Optional: perform actions after the handler executes })
    );
  }

  // Placeholder for fetching resource count. Replace with actual service call.
  private async getResourceCount(userId: string, resourceType: string): Promise{ number } {
    // Example: Fetch from a database or another microservice
    if (resourceType === 'courses') {
      return 2; // Mock count for BASIC plan
    }
    return 0;
  }
}

In this interceptor:

  • We inject Reflector to access route metadata.
  • We retrieve the metadata set by our custom decorator.
  • We extract the user from the request.
  • Crucially, we call a (mocked) service method `getResourceCount` to get the current number of resources the user has created. This is where the actual data fetching happens.
  • If the user is on the 'BASIC' plan and their current count meets or exceeds the defined limit, a ForbiddenException is thrown.
  • If all checks pass, next.call() proceeds to the actual route handler.

3. Applying the Decorator and Interceptor

Now, you can apply the custom decorator to your controller methods and use the interceptor. You can apply the interceptor globally in your `main.ts` or `app.module.ts`, or specifically to routes using `@UseInterceptors()`.

In the controller:

import { Controller, Post, UseInterceptors } from '@nestjs/common';
import { PlanLimitInterceptor } from './plan-limit.interceptor';
import { RequiresPlanLimit } from './requires-plan-limit.decorator';

@Controller('courses')
export class CoursesController {
  // ... constructor and other methods ...

  @Post()
  @UseInterceptors(PlanLimitInterceptor)
  @RequiresPlanLimit('course', 5) // BASIC plan allows 5 courses, PRO allows more (handled by different logic or another decorator)
  async createCourse() {
    // Logic to create a course
    return { message: 'Course created successfully' };
  }
}

In this setup, the @RequiresPlanLimit('course', 5) decorator tells the PlanLimitInterceptor that for this route, the resource type is 'course' and the limit for the basic plan is 5. The interceptor then executes before the createCourse method, enforcing the limit. If the user is on a 'PRO' plan, the logic within the interceptor would need to be more sophisticated, perhaps checking for a higher limit or a different set of rules. This could be managed by passing more parameters to the decorator or by having different decorators for different plan tiers.

Alternative: Using Guards

While Interceptors are excellent for logic that might run before or after a handler, NestJS Guards are specifically designed for authorization and can also be used for plan enforcement. Guards determine whether a request should be processed by the route handler. They are generally simpler than interceptors if your primary goal is to allow or deny access based on conditions.

You could create a PlanLimitGuard similar to the interceptor, but it would implement the CanActivate interface.

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  Reflector,
  ForbiddenException
} from '@nestjs/common';

@Injectable()
export class PlanLimitGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  async canActivate(context: ExecutionContext): Promise{ boolean } {
    const metadata = this.reflector.get('requiresPlanLimit', context.getHandler());

    if (!metadata) {
      return true;
    }

    const { resourceType, limit } = metadata;
    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user) {
      throw new ForbiddenException('User not authenticated');
    }

    // Fetch current count (same as in interceptor)
    const currentCount = await this.getResourceCount(user.id, resourceType);

    if (user.plan === 'BASIC' && currentCount >= limit) {
      throw new ForbiddenException(`Upgrade to create more ${resourceType}s`);
    }

    return true;
  }

  // Placeholder for fetching resource count. Replace with actual service call.
  private async getResourceCount(userId: string, resourceType: string): Promise{ number } {
    // Example: Fetch from a database or another microservice
    if (resourceType === 'courses') {
      return 2; // Mock count for BASIC plan
    }
    return 0;
  }
}

You would then apply this guard using the `@UseGuards()` decorator on your controller methods.

import { Controller, Post, UseGuards } from '@nestjs/common';
import { PlanLimitGuard } from './plan-limit.guard';
import { RequiresPlanLimit } from './requires-plan-limit.decorator';

@Controller('courses')
export class CoursesController {
  // ... constructor and other methods ...

  @Post()
  @UseGuards(PlanLimitGuard)
  @RequiresPlanLimit('course', 5)
  async createCourse() {
    // Logic to create a course
    return { message: 'Course created successfully' };
  }
}

The choice between Interceptors and Guards often comes down to preference and the specific context. Guards are more semantically aligned with authorization checks, while Interceptors offer more flexibility for manipulating the request/response lifecycle. For simple plan limit enforcement, a Guard might be slightly cleaner.

Handling Different Plan Tiers and Resources

The presented solution assumes a single limit for a 'BASIC' plan. In a real-world scenario, you’ll need to manage multiple plan tiers (e.g., FREE, BASIC, PRO, ENTERPRISE) with varying limits for different resources (courses, projects, API calls, storage, etc.).

The custom decorator and the associated interceptor/guard can be extended to handle this complexity. Instead of just a single limit, the decorator could accept a map of plan tiers to limits, or an object defining resource-specific limits.

For example, the decorator could look like this:

export const RequiresPlanLimits = (resourceLimits: {
  [plan: string]: {
    [resourceType: string]: number;
  };
}) => SetMetadata('requiresPlanLimits', resourceLimits);

The interceptor/guard would then parse this more complex metadata structure, fetching the user's current plan and comparing it against the appropriate limit for the requested resource type.

The Unanswered Question: Dynamic Limits and Edge Cases

While these solutions centralize the logic effectively, a key question remains: how do you handle dynamic limits that aren't static values? For instance, what if a user's limit increases mid-subscription due to a promotional offer or a feature upgrade that’s applied instantly? The current decorator-based approach relies on metadata set at compile time. Adapting it to handle runtime changes in user entitlements or plan configurations would require a more sophisticated metadata system or a different enforcement strategy, potentially involving a dedicated authorization service that the interceptor/guard queries dynamically.

By leveraging NestJS Interceptors or Guards with custom decorators, developers can move away from scattered conditional logic and implement a clean, maintainable, and scalable system for enforcing subscription plan limits across their applications.