# Guide: Add a Global Exception Handler ## Purpose This guide provides step-by-step instructions for adding a centralised exception handler to an **avaje-nima** project. The handler catches exceptions thrown by any controller, maps them to structured JSON error responses, and sets the correct HTTP status codes — without repeating error-handling logic in every endpoint. When asked to *"add a global exception handler"*, *"add centralised error handling"*, or *"add an error response"* to an avaje-nima project, follow these steps exactly. --- ## Overview The pattern uses two classes in a `web/exception` package: | Class | Purpose | |---|---| | `ErrorResponse` | JSON record returned in the response body for all errors | | `GlobalExceptionController` | `@Controller` with `@ExceptionHandler` methods; one per exception type | avaje's annotation processor generates the routing glue at compile time — no runtime configuration needed. > `avaje-nima` already transitively includes `avaje-jsonb` (`@Json`) and > `avaje-http-api` (`@ExceptionHandler`). Only `avaje-record-builder` needs to be > added explicitly. --- ## Step 1 — Add the `avaje-record-builder` dependency to `pom.xml` `ErrorResponse` uses `@RecordBuilder` to generate a builder. Add the dependency to `pom.xml` as a `provided`-scope annotation processor: ```xml io.avaje avaje-record-builder 1.4 provided ``` --- ## Step 2 — Create `ErrorResponse.java` Create the file at `src/main/java//web/exception/ErrorResponse.java`. Replace `` with the project's root package (find it by looking at existing controller imports or `module-info.java`). ```java package .web.exception; import io.avaje.jsonb.Json; import io.avaje.recordbuilder.RecordBuilder; @Json @RecordBuilder public record ErrorResponse( int httpCode, String path, String message, String traceId ) { public static ErrorResponseBuilder builder() { return ErrorResponseBuilder.builder(); } } ``` **Fields:** | Field | Description | |---|---| | `httpCode` | The HTTP status code (e.g. `400`, `404`, `500`) | | `path` | The request path where the error occurred | | `message` | A human-readable description of the error | | `traceId` | Distributed trace ID (set to `null` until tracing is integrated) | > `@RecordBuilder` instructs the `avaje-record-builder` processor to generate > `ErrorResponseBuilder` at compile time. The static `builder()` method delegates to > the generated builder. --- ## Step 3 — Create `GlobalExceptionController.java` Create the file at `src/main/java//web/exception/GlobalExceptionController.java`: ```java package .web.exception; import io.avaje.http.api.Controller; import io.avaje.http.api.ExceptionHandler; import io.avaje.http.api.Produces; import io.helidon.http.BadRequestException; import io.helidon.http.InternalServerException; import io.helidon.http.NotFoundException; import io.helidon.webserver.http.ServerRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @Controller final class GlobalExceptionController { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionController.class); private static final int HTTP_500 = 500; private static final int HTTP_400 = 400; private static final int HTTP_404 = 404; private static final String HTTP_500_MESSAGE = "An error occurred processing the request."; private static final String HTTP_404_MESSAGE = "Not found for "; @Produces(statusCode = HTTP_500) @ExceptionHandler ErrorResponse defaultErrorResponse(Exception ex, ServerRequest req) { logException(ex, path(req)); return ErrorResponse.builder() .httpCode(HTTP_500) .path(path(req)) .message(HTTP_500_MESSAGE) .build(); } @Produces(statusCode = HTTP_400) @ExceptionHandler ErrorResponse badRequest(BadRequestException ex, ServerRequest req) { logException(ex, path(req)); return ErrorResponse.builder() .httpCode(HTTP_400) .path(path(req)) .message(ex.getMessage()) .build(); } @Produces(statusCode = HTTP_500) @ExceptionHandler ErrorResponse internalServerError(InternalServerException ex, ServerRequest req) { logException(ex, path(req)); return ErrorResponse.builder() .httpCode(HTTP_500) .path(path(req)) .message(HTTP_500_MESSAGE) .build(); } @Produces(statusCode = HTTP_400) @ExceptionHandler(UnsupportedOperationException.class) ErrorResponse unsupportedOperation(UnsupportedOperationException ex, ServerRequest req) { return ErrorResponse.builder() .httpCode(HTTP_400) .path(path(req)) .message(ex.getMessage()) .traceId(null) .build(); } @Produces(statusCode = HTTP_404) @ExceptionHandler(NotFoundException.class) ErrorResponse notFound(ServerRequest req) { String path = path(req); log.debug("404 not found path:{}", path); return ErrorResponse.builder() .httpCode(HTTP_404) .path(path) .message(HTTP_404_MESSAGE + path) .traceId(null) .build(); } private static void logException(Exception ex, String path) { log.error("An error occurred processing request on path '{}'", path, ex); } private static String path(ServerRequest req) { return req != null && req.path() != null ? req.path().path() : null; } } ``` --- ## Step 4 — Key rules to follow 1. **`GlobalExceptionController` must be package-private** (`final class`, no `public`). avaje-inject discovers it from generated wiring regardless of visibility. 2. **`ErrorResponse` must be `public`** — it is part of the JSON API surface. 3. Both files go in the **same `web/exception` package** (or equivalent sub-package). 4. The `@ExceptionHandler` exception type is inferred from the first parameter; use `@ExceptionHandler(SomeException.class)` when the parameter type differs or is omitted (e.g. the 404 handler which has no exception parameter). 5. Always pair `@ExceptionHandler` with `@Produces(statusCode = N)` to set the correct HTTP status. ### Handler method signature rules avaje-http maps `@ExceptionHandler` methods by inspecting the first parameter type: ```java // Implicit — exception type inferred from first parameter @ExceptionHandler ErrorResponse handle(BadRequestException ex, ServerRequest req) { … } // Explicit — handles exactly UnsupportedOperationException @ExceptionHandler(UnsupportedOperationException.class) ErrorResponse handle(UnsupportedOperationException ex, ServerRequest req) { … } // No exception parameter — handler receives only the request (e.g. for 404) @ExceptionHandler(NotFoundException.class) ErrorResponse handle(ServerRequest req) { … } ``` ### Handler priority More-specific exception types take priority over broader ones. The `Exception` catch-all is the fallback: ``` NotFoundException → 404 BadRequestException → 400 InternalServerException → 500 UnsupportedOperationException → 400 Exception (catch-all) → 500 ``` --- ## Step 5 — Verify ```bash mvn compile ``` The build must succeed with no errors from the annotation processors. Then run and test: ```bash curl -i http://localhost:8080/no-such-path # Expected: HTTP 404, Content-Type: application/json ``` Expected response body: ```json {"httpCode":404,"path":"/no-such-path","message":"Not found for /no-such-path","traceId":null} ``` --- ## Notes - The `traceId` field is always `null` in this baseline. Populate it with a distributed trace ID (e.g. from a `traceparent` header) when tracing is integrated: ```java String traceId = req.headers().value(HeaderNames.create("traceparent")).orElse(null); ``` - To add handlers for additional exception types, add new methods following the same pattern: `@Produces(statusCode = N)` + `@ExceptionHandler` + exception type as first parameter. - The `Exception` catch-all is the fallback. More-specific types always take priority. --- ## Version compatibility | Component | Tested version | |---|---| | `avaje-record-builder` | 1.4 | | `avaje-nima` (includes `avaje-http-api`, `avaje-jsonb`) | 1.8 | | Helidon SE | 4.4.0 | | Java | 25 | --- ## References - avaje-http `@ExceptionHandler` docs: https://avaje.io/http/ - avaje-record-builder: https://github.com/avaje/avaje-record-builder