Skip to main content

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:

  1. No split between "HTTP status says OK but body is an error." Status and success agree.
  2. Clients have one shape to parse. body.data always exists on success; body.error.code always exists on failure.
  3. 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.failed with details: { field, constraint }[].
  • HttpException (Nest built-in) → map status to a domain code where known, else http.<status>.
  • MongoServerError with code 11000 → slug.conflict or 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 HttpException sub-classes (NotFoundException, BadRequestException, etc.) are translated by mapException. Don't throw raw HttpException with only a string — pass an object so the filter can see a code.
  • 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 BEFORE ResponseEnvelopeInterceptor so 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, assert body.success === true, body.data is an array, body.pagination.offset === 0.
  • Integration: hit GET /content/does-not-exist, assert body.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 in body.error.details when NODE_ENV=production.