Response envelope
Consistent { success, data, pagination?, meta? } on every success and { success: false, error, meta } on every error.
Purpose
Clients (the Chrome extension today; more later) branch on body.success first. This has three benefits:
- No split between "HTTP status says OK but body is an error." Status and
successagree. - Clients have one shape to parse.
body.dataalways exists on success;body.error.codealways exists on failure. - Server code stays normal. Controllers return plain DTOs; the wrapping happens in a global interceptor.
Contract reference: API.md §1.
Implementation
Two pieces: an interceptor (wraps successes) and an exception filter (wraps errors). Both registered globally in AppModule.
ResponseEnvelopeInterceptor
// src/common/interceptors/response-envelope.interceptor.ts
@Injectable()
export class ResponseEnvelopeInterceptor<T> implements NestInterceptor<T, Envelope<T>> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<Envelope<T>> {
const req = context.switchToHttp().getRequest<Request>();
const started = process.hrtime.bigint();
return next.handle().pipe(
map((data) => {
const base = { success: true as const, data, meta: toMeta(req, started) };
return isPaginated(data) ? { ...base, data: data.items, pagination: data.pagination } : base;
}),
);
}
}
const isPaginated = <T>(x: unknown): x is { items: T[]; pagination: Pagination } =>
!!x && typeof x === 'object' && 'items' in x && 'pagination' in x;
The interceptor detects Paginated<T> by structural shape and reshapes the envelope. Controllers never construct the envelope themselves.
AllExceptionsFilter
// src/common/filters/all-exceptions.filter.ts
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const http = host.switchToHttp();
const res = http.getResponse<Response>();
const req = http.getRequest<Request>();
const mapped = mapException(exception); // → { status, code, message, details? }
res.status(mapped.status).json({
success: false,
error: { code: mapped.code, message: mapped.message, details: mapped.details },
meta: toMeta(req),
});
}
}
mapException handles:
ValidationException(class-validator) →validation.failedwithdetails: { field, constraint }[].HttpException(Nest built-in) → mapstatusto a domain code where known, elsehttp.<status>.MongoServerErrorwith code 11000 →slug.conflictor similar (see mapping table).Error(generic) →internal.error, log with stack, strip details in prod.
Paginated<T> helper
// src/common/dto/paginated.dto.ts
export interface Paginated<T> {
items: T[];
pagination: { offset: number; limit: number; total: number; hasMore: boolean };
}
export const paginated = <T>(items: T[], total: number, q: PaginationQueryDto): Paginated<T> => ({
items,
pagination: {
offset: q.offset,
limit: q.limit,
total,
hasMore: q.offset + items.length < total,
},
});
Services return Paginated<Dto>; the interceptor notices and builds the correct envelope.
meta attribution
toMeta(req, started?) builds { requestId, durationMs? }. requestId comes from req.requestId populated by RequestIdMiddleware (see OPERATIONS.md).
Required variables and services
- None. Pure plumbing. Registered in
app.module.ts:
providers: [
{ provide: APP_INTERCEPTOR, useClass: ResponseEnvelopeInterceptor },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
Middleware registration in main.ts:
app.use(requestIdMiddleware); // before ValidationPipe so logs have requestId on validation errors too
Gotchas
- Streaming responses. The interceptor rewraps buffers — a future file-download endpoint needs to skip the interceptor via a
@Raw()decorator (not built yet; design reserved). - Nest's built-in
HttpExceptionsub-classes (NotFoundException, BadRequestException, etc.) are translated bymapException. Don't throw rawHttpExceptionwith only a string — pass an object so the filter can see acode. - 204 No Content. We do not use 204. Always return 200 with
{ success: true, data: null }. Status/body consistency beats HTTP purism. - Serialization order.
ClassSerializerInterceptor(which strips@Exclude()'d fields) must run BEFOREResponseEnvelopeInterceptorso the envelope wraps already-cleaned data. Register in that order.
Testing
- Unit:
mapException— throw each known error shape, assert the output shape. - Integration: hit
GET /content, assertbody.success === true,body.datais an array,body.pagination.offset === 0. - Integration: hit
GET /content/does-not-exist, assertbody.success === false,body.error.code === 'content.not_found', status 404. - Integration: throw
new Error('boom')in a test-only controller, assert envelope shape and that the stack is NOT inbody.error.detailswhenNODE_ENV=production.