Success helpers
apps/core-api/src/http/respond.ts exports three functions. Controllers use nothing else to write a success response.
ok writes the envelope. requestId is included when the request id middleware ran, which is always the case for routed requests.
@perform/types:
Pagination
List endpoints takepage and pageSize from the query schema and return the Paginated shape inside data:
pageSize is usually 20. The maximum is set per module in its Zod schema and is not uniform. productListQuery allows up to 500. Read the schema instead of assuming 100.
AppError
@perform/errors exports the one error class services throw.
status defaults from the code. Pass options.status only when a code needs a different status.
Codes
ErrorCode is an as const object in @perform/types, re-exported by @perform/errors.
details
details is free form and reaches the client unchanged. The codebase uses a reason string inside it as a machine readable sub code, so clients can branch without parsing messages:
The trainee app logs the user out on any
401. That is why a wrong OTP is BAD_REQUEST and a frozen subscription is FORBIDDEN with a reason. Do not answer 401 for anything other than a session that is really gone.
The error middleware
errorMiddleware in @perform/errors is registered last in app.ts. It normalizes whatever was thrown, in this order:
1
AppError
Used as is.
2
Prisma errors
mapPrismaError recognizes Prisma errors by err.name, not by instanceof, so it works across the two Prisma client packages.3
Body parser errors
mapBodyParserError handles errors from express.json and express.raw (objects with type, expose: true and a 4xx status). The result is BAD_REQUEST with the parser’s own status: payload too large for 413, invalid request body otherwise.4
ZodError
Status 422, code
VALIDATION, message validation failed, and details as a list of { path, message } taken from err.issues.5
Anything else
Status 500, code
INTERNAL, message internal server error. The original message is never sent to the client.AppError with a useful message.
What is logged
The middleware logs every error withconsole, not pino:
- 5xx:
console.error('[error]', { requestId, path, method, code, name, message }). - 4xx:
console.warn('[client-error]', { requestId, path, method, status, code, message }).
statusCode: 400 lines.
The stack is not logged by the middleware. If you need a stack for a failure, log it where it happens with ctx.logger.error({ err }, 'message'). pino serializes err with its stack.
Not found
notFoundMiddleware answers any unmatched route with 404, code NOT_FOUND, message route not found: METHOD /path. Under /v1 an unknown path usually hits replayProtection first. See the warning on Route lanes.
Logging errors outside a request
Two helpers exist and they belong to different package families.serializeError returns { name, message, code?, stack? } with the stack cut to four frames. logError logs { error: serializeError(error), ...context }. Nothing under apps/core-api/src calls logError. Use the pino logger from the context there.
Never swallow, but know the best effort pattern
A handler must not hide a failure of its main job. Side effects that must not fail the main job are a different matter, and the code has a consistent shape for them: do the main write, then run the side effect in atry/catch or a .catch that logs and continues.
Examples: registerPushToken saves the token and then tries to send pending form pushes. updateFoodPreferences saves and then tries to write a timeline entry. sendTraineeNotification catches everything and returns an empty result, because a push failure must not fail the coach’s action.
When you add one, log it with enough context to find it later, and never use it around the main write.
Lanes with their own shape
Automation error mapping
automationErrorMiddleware is registered on the automation router, so the global middleware never sees these errors.
ZodErrorbecomes400with one readable line:Invalid request: phone is required; .... At most five issues are listed, then(and N more).AppErroruses a narrower status map:UNAUTHORIZED401,FORBIDDEN403,CONFLICT409,RATE_LIMITED429,SERVICE_UNAVAILABLE503,INTERNAL500. Every other code, includingNOT_FOUNDandVALIDATION, becomes400, because Make surfaces a 400 with the reason best.- Anything else is
500with the error’s own message, logged withconsole.error('[automation-api]', err).