Cross-cutting concerns

Guards, interceptors, and filters — all DI'd providers, differing only in when they run.

Guards, interceptors, and filters are all @Injectable providers, resolved from the per-request scope. They share one model and differ only in when they run in the pipeline:

guards → interceptors (pre) → validate → handler → interceptors (post) → [catch] filters

Because they're DI'd, each can inject services — a DB-backed permission guard, a logging interceptor, an audit filter. You reference the class on a decorator; roost maps it to its DI token and resolves it per request.

Guards — @UseGuards

A guard throws to reject (preserving the exact status), or returns to allow.

@Injectable()
export class MemberGuard implements RoostGuard {
  canActivate(ctx: Ctx): void {
    if (!ctx.user) throw createError({ statusCode: 401 })
    if (ctx.user.role !== 'member') throw createError({ statusCode: 403 })
  }
}

@Controller('projects')
@UseGuards(MemberGuard) // applies to every method; method-level guards merge on top
export class ProjectsController {}

Class + method guards are merged class-first and de-duped.

Interceptors — @UseInterceptors

"Around" advice. next() runs the next interceptor (or the handler). Transform the result, time it, short-circuit by not calling next().

@Injectable()
export class AuditInterceptor implements RoostInterceptor {
  constructor(private readonly ctx: RequestContext) {}
  async intercept(ctx: Ctx, next: () => Promise<unknown>) {
    const started = Date.now()
    try {
      return await next()
    } finally {
      console.log(`${ctx.event.path} tenant=${this.ctx.tenantId} ${Date.now() - started}ms`)
    }
  }
}

Filters — @UseFilters

Map a thrown error to a response. Return undefined to pass to the next filter, an Error (use createError) to throw with its status, or any value to send as the body.

@Injectable()
export class DomainErrorFilter implements RoostFilter {
  catch(err: unknown, _ctx: Ctx) {
    if (err instanceof NotFoundError)
      return createError({ statusCode: 404, statusMessage: err.message })
    return undefined // not ours
  }
}

Filters dispatch method-before-class (a route can override a controller default), then global filters, then a built-in fallback that maps HttpError (see below) and ZodError → 400. So a bad request body returns 400 out of the box, no filter required.

Exceptions — the leaf case

A filter is for the cross-cutting case: map a whole class of errors in one place, with injected services to log or audit. For the leaf case — reject with a status right where you are — throw a prebuilt exception instead. They're auto-imported, so feature code needs no import and never reaches for createError from h3:

@Scoped()
export class ProjectsService {
  constructor(private readonly repo: ProjectsRepo) {}

  get(id: string): ProjectRecord {
    const found = this.repo.find(id)
    if (!found) throw new NotFoundException(`project ${id} not found`) // -> 404, no filter needed
    return found
  }
}

HttpError is the base — statusCode, message, and an optional data payload. The built-in set covers the common statuses:

ExceptionStatus
BadRequestException400
UnauthorizedException401
ForbiddenException403
NotFoundException404
ConflictException409
GoneException410
UnprocessableEntityException422
TooManyRequestsException429
InternalServerErrorException500
ServiceUnavailableException503

The set stops at the common app-thrown statuses on purpose (infra/proxy statuses like 502/504 are set by load balancers, not domain code). For anything else, throw the base directly — throw new HttpError(418, "I'm a teapot") — or extend HttpError for an app-specific exception you reuse. The built-in fallback maps any HttpError to its status — exactly like ZodError → 400 — so guards, services, and handlers all reject the same way, named alias or not. A guard, for instance, throws new ForbiddenException() rather than importing createError:

@Injectable()
export class MemberGuard implements RoostGuard {
  canActivate(ctx: Ctx): void {
    if (!ctx.user) throw new UnauthorizedException()
    if (ctx.user.role !== 'member') throw new ForbiddenException('Members only')
  }
}

Filter or exception? Throw an exception when you're setting a status at one call site; write a filter when the mapping is cross-cutting. They compose — a filter gets first crack, the built-in fallback catches the rest.

Globals

Apply a chain to every route at runtime — no decorator, no regeneration:

nuxt.config.ts
roost: {
  globals: {
    guards: ['authGuard'],
    interceptors: ['auditInterceptor'],
    filters: ['sentryFilter'],
  },
}

Globals are runtime-applied (not baked into each route file), but they still show up in .roost/manifest.json annotated (global), so the full effective chain per route stays visible.

Async everywhere

Every stage is awaited, so guards, interceptors, filters, and handlers can all be async — a guard can await a DB membership check, a handler can await a service, an interceptor can time the awaited work. canActivate / intercept / catch / the handler each return T | Promise<T>; sync and async compose identically. The playground's reports feature shows it end to end: an async guard, an async controller method, and an async service doing concurrent I/O.

Copyright © 2026