Cross-cutting concerns
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:
| Exception | Status |
|---|---|
BadRequestException | 400 |
UnauthorizedException | 401 |
ForbiddenException | 403 |
NotFoundException | 404 |
ConflictException | 409 |
GoneException | 410 |
UnprocessableEntityException | 422 |
TooManyRequestsException | 429 |
InternalServerErrorException | 500 |
ServiceUnavailableException | 503 |
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:
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.