CodeGym /Courses /Spring REST & MVC /Designing GlobalExceptionHandler

Designing GlobalExceptionHandler

Spring REST & MVC
Level 20, Lesson 2
Available

1. The role of domain exceptions in error handling

Domain exceptions are not “something went wrong in Spring,” but a normal and expected part of your application's behavior. This is the moment when the client makes a logical request and the system honestly responds: “resource not found” or “this action is forbidden by a business rule.” If you handle domain errors haphazardly, the API turns into a sitcom: “guess the status code by the service's mood.”

Imagine: a client requests GET /api/v1/tasks/{taskId}, but the task does not exist. That is not a “server error,” and certainly not a reason to return 500 and pretend that Spring “could not cope.” This is a normal scenario branch for which we must be able to consistently return 404 Not Found and a clear ProblemDetail. Likewise, if the client tries to attach a file to an archived task or make a forbidden status transition, that is not an “invalid JSON” or “wrong query param” issue, but a business conflict that should become an honest 409 Conflict.

The key takeaway for this lecture: a domain exception should be a signal of meaning, and the GlobalExceptionHandler should be a translator of that meaning into HTTP responses, consistent across the entire API.

2. The boundary between layers: domain and web-layer

To avoid turning the application into chaos, it is important to keep the boundary: the service layer speaks about a domain problem (through an exception), and the web-layer decides what that looks like in HTTP. Otherwise, you end up with services that know about HttpStatus, ResponseEntity, application/problem+json, and then you wonder why they are impossible to test and reuse properly without MVC.

It is very useful to mentally “run” a request through the pipeline once. In our course, we simplify the infrastructure (in-memory repositories), but the error flow still resembles production: the controller calls the service, the service works with the repository, on a problem it throws a domain exception, it “bubbles up” back, and @ControllerAdvice turns it into ProblemDetail.

flowchart TD
    A[HTTP request] --> B["@RestController"]
    B --> C[TaskService]
    C --> D[In-memory repository]
    D --> C
    C -->|throws DomainException| E["@ControllerAdvice GlobalExceptionHandler"]
    E --> F["ResponseEntity<ProblemDetail>"]
    F --> G[HTTP response]

Why is this diagram so important right now? Because it preserves layer purity. Controllers become short and readable, services do not “leak” HTTP details, and the whole error style is collected in one place. And yes — this is the case where “one file for errors” unexpectedly makes the project simpler, not harder.

One shared advice already knows how to turn an exception into an HTTP response. Now it is important to make the exceptions themselves meaningful for the application: if random RuntimeExceptions with different strings reach the advice, the contract will drift again. That is why domain errors need their own proper model.

3. Domain exceptions without HTTP

Before extending GlobalExceptionHandler with domain rules, we need to decide what domain exceptions will look like at all. Here we want to create the simplest possible, yet disciplined model. It is important that the domain exception be completely “Spring-free”: it must not import HttpStatus, ResponseEntity, ProblemDetail, or anything from MVC at all. Its job is to express the application meaning of the problem.

For the Task Tracker API, it is convenient to have two elements: ErrorCode (a machine-readable error code that will end up in ProblemDetail) and a base domain exception class that carries that code. This is not a “by the book” requirement, but simply a way to avoid thousands of lines of copy-paste and “magic strings” in different places.

ErrorCode: the domain error dictionary

Let's start with a simple enum. It lives in the domain layer (for example, com.example.tasktracker.domain.exception), because these codes are part of the application meaning. The web-layer will then use them to build the response.

package com.example.tasktracker.domain.exception;

public enum ErrorCode {
    // Machine-readable codes: they are convenient to return to the client in ProblemDetail (code field)
    TASK_NOT_FOUND,
    COMMENT_NOT_FOUND,
    INVALID_STATUS_TRANSITION,
    FILE_UPLOAD_NOT_ALLOWED
}

There is discipline here already: the error code is not taken “out of thin air” as the string "task_not_found" in one place and "TASK_NOT_FOUND" in another. One enum — one source of truth. If you make a mistake in the code name, the compiler will be your strict friend (not “let's check the logs later”).

Base DomainException: meaning + code, no HTTP

Now the base class. It stores the ErrorCode and a message. The message (message) is what will usually become detail in ProblemDetail. The code is what will become code. For now we are intentionally not dragging HTTP status in here so as not to break the layers.

package com.example.tasktracker.domain.exception;

public abstract class DomainException extends RuntimeException {

    // Domain error code: it is not about HTTP, but about meaning (what exactly is violated)
    private final ErrorCode code;

    protected DomainException(ErrorCode code, String message) {
        super(message); // We will use the message as detail in ProblemDetail
        this.code = code;
    }

    public ErrorCode getCode() {
        return code;
    }
}

This is a very small class, but it does an important thing: it standardizes the contract of domain errors. Every domain exception now has an error code “by default.”

Specific exception: TaskNotFoundException

Next comes the specific case. For example, a task missing by id. Notice that the exception speaks about the domain (“task not found”), not the transport (“404 not found”). This is a subtle but fundamental difference.

package com.example.tasktracker.domain.exception;

public class TaskNotFoundException extends DomainException {

    public TaskNotFoundException(String taskId) {
        // Important: here we fix the domain meaning (TASK_NOT_FOUND), not the HTTP status
        super(ErrorCode.TASK_NOT_FOUND, "Task '%s' not found".formatted(taskId));
    }
}

In a similar way, CommentNotFoundException, InvalidStatusTransitionException, and others are created. The main rule: the exception should be readable in logs and understandable as meaning, but it must not turn into an “HTTP exception in the domain.”

Mapping domain errors: exception → HTTP status → ProblemDetail + code

When the exceptions are ready, the main question arises: who decides which status to return? The answer is GlobalExceptionHandler. And so that it does not become a “giant wall of conditions,” it is useful to write the mapping down in advance as a small contract. This is not bureaucracy — it is a way to make the API predictable.

Below is an example of a minimal mapping table for domain scenarios. We intentionally do not add framework-level errors here (malformed JSON, type mismatch, unsupported media type) — for them, this same handler will have a different branch through ResponseEntityExceptionHandler.

Domain scenario Example exception HTTP status code
Resource not found TaskNotFoundException 404 Not Found TASK_NOT_FOUND
Subresource not found CommentNotFoundException 404 Not Found COMMENT_NOT_FOUND
Forbidden status transition InvalidStatusTransitionException 409 Conflict INVALID_STATUS_TRANSITION
Forbidden operation (for example, upload to an archived task) FileUploadNotAllowedException 409 Conflict FILE_UPLOAD_NOT_ALLOWED

The important thing here is that one meaning should consistently produce one status. If sometimes TASK_NOT_FOUND becomes 404, sometimes 400, and sometimes 200 with the message "error": "not found", client code will start to suffer. And suffering client code is like tests that fail “sometimes”: maybe not your problem, but annoying every day.

4. Implementing GlobalExceptionHandler

Now let's assemble the handler itself. We want it to look like a “rule table”: which exception — which status — which code — which title. And at the same time, ProblemDetail should be assembled consistently, without repeating the same code in every method.

Scaffold for @RestControllerAdvice

The class itself is usually convenient to keep in the web/api zone, for example com.example.tasktracker.api.controller.advice. This is part of the API layer, so Spring annotations are appropriate here.

package com.example.tasktracker.api.controller.advice;

import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice // One shared advice for the entire REST web-layer
public class GlobalExceptionHandler {
}

It is empty for now, but this is already an “anchor” — the only place where the error rules will live centrally.

Helper for building ProblemDetail

Building ProblemDetail manually in every @ExceptionHandler quickly gets tedious. So we create a small helper: the base overload builds the common response shape, and the domain branches simply add machine-readable code to it. That way the payload does not split in two: the source of the error changes, not the response format.

import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;

private ProblemDetail problem(HttpStatusCode status, String title, String detail) {
    // Basic ProblemDetail skeleton: status + detail
    ProblemDetail body = ProblemDetail.forStatusAndDetail(status, detail);

    // Short title for a human
    body.setTitle(title);

    return body;
}

private ProblemDetail problem(
        HttpStatusCode status,
        String title,
        String detail,
        String code
) {
    // Domain errors use the same ProblemDetail shape, just with an additional code
    ProblemDetail body = problem(status, title, detail);
    body.setProperty("code", code);
    return body;
}

Notice the “subtle security and contract quality point.” In detail we put a clear but controlled message. We do not put ex.toString() there, because that almost certainly pulls extra internals into the public contract. For domain exceptions, ex.getMessage() usually works, because we formed it ourselves and know what is written there.

Handler for TaskNotFoundException → 404

Now let's add a concrete mapping. For TaskNotFoundException, we always return 404 and code TASK_NOT_FOUND. title is a short human-readable heading, and detail explains the situation more specifically.

import com.example.tasktracker.domain.exception.TaskNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
    ProblemDetail body = problem(
            HttpStatus.NOT_FOUND,      // HTTP status for the "resource not found" scenario
            "Task not found",          // Short title
            ex.getMessage(),           // detail: controlled message from the domain exception
            ex.getCode().name()        // machine-readable code (for example, TASK_NOT_FOUND)
    );

    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}

Yes, here you can see that ex.getCode().name() produces the string TASK_NOT_FOUND. That is exactly the machine-readable code that is convenient for client-side processing.

Handler for domain conflicts → 409

Business conflict errors (forbidden status transition, upload forbidden, etc.) are 409 Conflict. Here, too, consistency matters: 409 is not “something is wrong with JSON,” but “the input is fine, but the action contradicts the rules.”

Example for a status transition:

import com.example.tasktracker.domain.exception.InvalidStatusTransitionException;

@ExceptionHandler(InvalidStatusTransitionException.class)
public ResponseEntity<ProblemDetail> handleInvalidStatusTransition(InvalidStatusTransitionException ex) {
    ProblemDetail body = problem(
            HttpStatus.CONFLICT,       // Business-rule conflict, not a request format error
            "Invalid status transition",
            ex.getMessage(),
            ex.getCode().name()
    );

    return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}

This way domain scenarios do not create a second error format: they simply enrich the same ProblemDetail with the code field.

General handler for the “unexpected” domain

Sometimes it is tempting to make one @ExceptionHandler(DomainException.class) and map the status by ErrorCode. This is allowed, but I would not do this as the first step: for a beginner, separate methods for separate exceptions are easier to read. When there are many exceptions, you can refactor carefully, but for now it is better to optimize for “code learnability” than for ultra-universality.

5. Controller and service after handling

Once domain errors are centralized, the controller finally stops being the place where errors are “assembled.” It talks only about the happy path: what data it accepts and what it returns on success. And if something goes wrong, the exception will bubble up, and GlobalExceptionHandler will make the correct response.

Example service: it looks up a task and, if it does not find one, throws TaskNotFoundException. The service does not build ProblemDetail and does not choose a status code — it just honestly reports the domain meaning.

import com.example.tasktracker.domain.exception.TaskNotFoundException;

public TaskDetailsResponse getById(String taskId) {
    // The service speaks the language of the domain: "task not found", not "return 404"
    Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new TaskNotFoundException(taskId));

    // Happy path: map the domain entity to a DTO
    return taskMapper.toDetails(task);
}

And now the controller. It becomes very short, and this is not a “loss of control,” but rather a sign of a healthy architecture.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/{taskId}")
public TaskDetailsResponse getTask(@PathVariable String taskId) {
    return taskService.getById(taskId);
}

If the task exists — you get 200 OK and a DTO. If not — the service throws an exception, and you get 404 with application/problem+json and code TASK_NOT_FOUND.

An example of what the client will see (simplified):

{
  "type": "about:blank",
  "title": "Task not found",
  "status": 404,
  "detail": "Task 'b3d2...' not found",
  "code": "TASK_NOT_FOUND"
}

Yes, type will be about:blank by default, and instance may be empty — that is normal for the current step. The most important thing today: stabilize the statuses and code for domain exceptions and stop spreading error handling across controllers.

6. Fallback for 500

Even if you have handled all domain exceptions perfectly, life will definitely throw something unexpected at you: NullPointerException, a bug in the mapper, an error in the in-memory repository, and so on. You must have one “safety net” handler that returns 500 but does not expose internal details to the outside.

This is the place where beginners often make two extreme moves. First — return ex.getMessage() to the outside and thereby publish internal details (sometimes even data fragments). Second — return one phrase "Error" and lose diagnostics. The right balance: send a controlled message outside, and write details into the logs.

Example of a minimal handler:

import org.springframework.http.HttpStatus;

@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
    // Last line of defense: catch everything unexpected and return a unified error contract
    // Important: do NOT expose ex.getMessage() to the outside so internal details do not leak
    ProblemDetail body = problem(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "Internal error",
            "Unexpected error",
            "INTERNAL_ERROR"
    );

    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}

Yes, INTERNAL_ERROR here is not from the domain ErrorCode: it is a technical marker in the same payload. The principle is still the same — unexpected errors must not fall out into a separate format and turn into responses “as it happens.”

7. Common mistakes when designing GlobalExceptionHandler

Mistake #1: the domain layer starts importing HttpStatus and “understanding HTTP.”
This often happens when you want to “simplify” and write throw new DomainException(HttpStatus.NOT_FOUND, ...). You quickly save a couple of lines, but lose the architecture: the service becomes web-specific, and the boundary between layers gets blurred. It is better to keep HTTP mapping in @ControllerAdvice.

Mistake #2: the same error meaning is mapped to different status codes.
Today you decided that TASK_NOT_FOUND is 404; tomorrow in another controller someone returns 400 because “the client passed the wrong id.” The client code does not care how you explain it: it needs stable behavior. One exception should have one stable status.

Mistake #3: ex.toString() or a technical “wall of text” leaks outside instead of a controlled detail.
ex.toString() often includes the exception class name, sometimes nested details, sometimes internal values. This easily becomes a public leak of project internals. For domain exceptions, ex.getMessage() is usually enough because you generate it yourself. For unexpected errors, it is better to return a fixed detail.

Mistake #4: copy-pasting ProblemDetail assembly in every handler.
At first it seems like “well, there are only three methods.” Then it becomes seven. Then fifteen. Then in one place code is set via setProperty, in another it is forgotten, in a third the field is called errorCode, and the contract spreads apart. A helper method is not an “extra layer,” it is protection against drift.

Mistake #5: one shared @ExceptionHandler(Exception.class) without specific domain handlers.
This “works,” but kills precision. In the end, TaskNotFoundException goes to 500, the client receives "Internal error", and starts thinking the server crashed. But in reality, the server just honestly did not find the task. In an API, that is a big difference.

1
Task
Spring REST & MVC, level 20, lesson 2
Locked
Domain exceptions with `ErrorCode` for the warehouse API
Domain exceptions with `ErrorCode` for the warehouse API
1
Task
Spring REST & MVC, level 20, lesson 2
Locked
Two domain exceptions with different HTTP statuses
Two domain exceptions with different HTTP statuses
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION