Skip to content

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.

lib/app/exceptions/app_exception.dart
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:

lib/app/http/controllers/user.controller.dart
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.

lib/app/http/response_handler.dart
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).

lib/app/error_handler.dart
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

lib/config/app.dart
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:

Route.get('/boom', (DoxRequest req) => throw StateError('boom'));
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