cursora

The problem with the obvious approach

The first version of plan enforcement in any product looks the same: a controller method starts with a check.

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

Enter fullscreen mode Exit fullscreen mode

It works, for exactly as long as there is one such rule. Ours has several — a cap on total courses, a separate cap on how many of those may be paid, a cap on standalone lessons — and each applies at a different endpoint. Copy that check into six controllers and you have six places to update when a limit changes, and one of them will be missed.

What we did instead: a decorator that names the action

Enforcement moved into a guard, and controllers only declare which limited action they perform:

@Post()
@EnforcePlanLimit('CREATE_COURSE')
async createCourse(@Body() dto: CreateCourseDto) { ... }

Enter fullscreen mode Exit fullscreen mode

The decorator is just metadata plus the guard, composed:

export const EnforcePlanLimit = (action: PlanLimitAction) =>
  applyDecorators(
    SetMetadata(PLAN_LIMIT_ACTION_KEY, action),
    UseGuards(PlanLimitGuard),
  );

Enter fullscreen mode Exit fullscreen mode

The guard reads that metadata with Reflector, resolves the user, and delegates to a single service that owns every limit rule:

const action = this.reflector.getAllAndOverride<PlanLimitAction | 'BYPASS'>(
  PLAN_LIMIT_ACTION_KEY,
  [context.getHandler(), context.getClass()],
);
if (!action || action === 'BYPASS') return true;

await this.planLimitsService.enforceOrThrow(userId, action);

Enter fullscreen mode Exit fullscreen mode

Now a limit change is a one-file change. The controllers never learn the numbers.

The part that isn't in the tutorials: conditional enforcement

The interesting cases are the ones where the same endpoint is sometimes limited and sometimes not.

Marking a course as paid is limited — but the endpoint that does it is a general update endpoint that also handles a dozen unlimited edits. Blocking it wholesale would mean free-tier users can't rename their own courses. So the guard inspects the request before deciding to enforce at all:

private shouldEnforce(action: PlanLimitAction, request: Request): boolean {
  if (action === 'SET_COURSE_PAID') {
    const isPaid = request.body?.isPaid;
    return isPaid === true || isPaid === 'true';
  }
  ...
}

Enter fullscreen mode Exit fullscreen mode

Same story for standalone lessons: a lesson created inside a course doesn't count against the standalone-lesson cap, and the only way to tell them apart is whether courseId is present in the body. That check lives in the guard too, next to the rule it belongs to, rather than being smeared across the lesson service.

The === 'true' string comparison is not an accident either — multipart form bodies deliver booleans as strings, and a limit that silently stops applying for one content type is worse than no limit.

An explicit escape hatch beats an implicit one

Some routes must never be limited — admin tooling, internal migrations. Rather than let those quietly work because nobody remembered to add the decorator, there's an explicit opt-out that reads as a deliberate decision in review:

export const BypassPlanLimit = () => SetMetadata(PLAN_LIMIT_ACTION_KEY, 'BYPASS');

Enter fullscreen mode Exit fullscreen mode

"No decorator" and "deliberately unlimited" look identical in a diff otherwise. They shouldn't.

What this bought us

When we published our pricing publicly this week — free tier: 3 courses, free-only; paid tiers lifting the course, paid-course and lesson caps — the numbers in the marketing copy came from one service, and changing them meant editing one file. That's the actual payoff: the pricing page and the enforcement can't drift apart, because there's only one place that knows.

Curious how others handle the conditional case — enforcement that depends on the request body, not just the route. Guard, interceptor, or push it down into the service? Interested in arguments for the other two.