Custom errors
Give your app its own exception classes, send every error in one JSON shape, and log the unexpected ones.
What you need
- A Dox app.
How Dox handles an error
When a controller or a middleware throws:
| Error | Status | What Dox does |
|---|---|---|
an IHttpException, like NotFoundHttpException |
its code |
sends {"code": ..., "message": ...}. Does not call report |
| a validation error (bad DTO input) | 422 |
sends the field errors: {"field": "message"}. Does not call report |
any other Exception or Error |
500 |
sends {"code":"server_error","message":"Server Error"}. When APP_ENV is development or local, it sends the error text. Calls report |
report is a method of the exception handler. The response goes through the responseHandler of AppConfig last. This guide sets the shape there.
1. Write your own exceptions
For a quick error, use abort(403) or abort(409, 'The email is taken'). See HTTP exceptions.
For errors you throw in many places, write a class.
Extend IHttpException. Set code (the HTTP status), errorCode (a short text for the client) and message.
import 'dart:io';
import 'package:dox_core/dox_core.dart';
/// 409. Thrown when the row already exists or was changed by someone else.
class ConflictException extends IHttpException {
ConflictException({String message = 'Conflict'}) {
super.code = HttpStatus.conflict;
super.errorCode = 'conflict';
super.message = message;
}
}
Throw them anywhere in a controller, a middleware or a service:
import 'package:dox_core/dox_core.dart';
import 'package:my_app/app/exceptions/app_exception.dart';
import 'package:my_app/app/models/user/user.model.dart';
@Inject()
class UserController {
/// GET /api/users/{id}
Future<User> show(int id) async {
User? user = await User().find(id);
if (user == null) {
throw NotFoundHttpException(message: 'User $id not found');
}
return user;
}
/// DELETE /api/users/{id}
Future<Map<String, bool>> destroy(int id) async {
if (id == 1) {
throw ForbiddenHttpException(message: 'The first user can not be deleted');
}
User user = await show(id);
/// 0 rows: the user was deleted after show() found it
if (await user.delete() == 0) {
throw NotFoundHttpException(message: 'User $id not found');
}
return <String, bool>{'deleted': true};
}
}
2. One JSON shape for every error
The response handler sees every response before it is sent. For an error, the content is the exception object. Turn it into your shape here.
import 'dart:io';
import 'package:dox_core/dox_core.dart';
/// Gives every error the same JSON shape:
/// `{"error": {"code": "not_found", "message": "..."}}`.
/// A validation error (422) also has `"errors": {field: message}`.
class ResponseHandler extends ResponseHandlerInterface {
@override
DoxResponse handle(DoxResponse res) {
dynamic content = res.getContent();
if (content is ValidationException) {
return res.statusCode(content.code).content(<String, dynamic>{
'error': <String, dynamic>{
'code': content.errorCode,
'message': 'The input is not valid',
},
'errors': content.message,
});
}
if (content is IHttpException) {
return res.statusCode(content.code).content(<String, dynamic>{
'error': <String, dynamic>{
'code': content.errorCode,
'message': content.message.toString(),
},
});
}
// any other exception: hide the details from the client
if (content is Exception || content is Error) {
return res.statusCode(HttpStatus.internalServerError).content(
<String, dynamic>{
'error': <String, String>{
'code': 'server_error',
'message': 'Something went wrong',
},
},
);
}
return res;
}
}
The last if gives a 500 your shape too, in every environment.
Without it, the client gets the default body. When APP_ENV is development or local, that is the error text. It can show a SQL query or a file path.
3. Log unexpected errors
The report method of the exception handler runs for every 500, and for errors outside a request (a future nobody awaited, a timer).
import 'dart:convert';
import 'package:dox_core/dox_core.dart';
/// Dox calls [report] for every unexpected error (a `500`, or an error
/// outside a request). It does not run for `IHttpException` errors
/// like `404` or `422`, because those are expected.
/// One JSON line per error. Send it to Sentry or Datadog from here.
class AppExceptionHandler extends ExceptionHandler {
const AppExceptionHandler();
@override
void report(Object error, StackTrace stackTrace) {
print(
jsonEncode(<String, String>{
'level': 'ERROR',
'message': error.toString(),
'stack': stackTrace.toString(),
'timestamp': DateTime.now().toIso8601String(),
}),
);
}
}
4. Register both
import 'package:my_app/app/error_handler.dart';
import 'package:my_app/app/http/response_handler.dart';
AppConfig appConfig = AppConfig(
responseHandler: ResponseHandler(),
exceptionHandler: const AppExceptionHandler(),
// ...
);
Check it
Add a route that throws, only for this check:
| Request | Status | Body |
|---|---|---|
GET /api/users/999 |
404 |
{"error":{"code":"not_found","message":"User 999 not found"}} |
GET /api/users/abc |
404 |
{"error":{"code":"not_found","message":"Not Found"}} |
DELETE /api/users/1 |
403 |
{"error":{"code":"forbidden","message":"The first user can not be deleted"}} |
POST /api/auth/register with {} |
422 |
{"error":{"code":"validation_failed","message":"The input is not valid"},"errors":{"name":"The name is required", ...}} |
GET /api/boom |
500 |
{"error":{"code":"server_error","message":"Something went wrong"}} |
After GET /api/boom, the server prints one line:
{"level":"ERROR","message":"Bad state: boom","stack":"#0 ApiRouter.register.<anonymous closure> (package:my_app/routes/api.dart:41:44) ...","timestamp":"2026-10-22T14:01:12.000Z"}
Remove the /boom route when you are done.
A URL with no route
A URL that matches no route is a NotFoundHttpException with the message GET /nothing not found.
It goes through the response handler too, so it gets the same JSON shape:
{"error":{"code":"not_found","message":"GET /nothing not found"}}.
See also
- Error handling for
reportandrender - Response: errors for the built-in exceptions
- Response: change every response
- Validation: the error response
- Isolate: when an isolate crashes