CodeGym /Courses /Spring Test /@ControllerAdvice and ApiProblem

@ControllerAdvice and ApiProblem

Spring Test
Level 12, Lesson 2
Available

1. A unified error contract as part of the API

When you write a controller, it is very tempting to think that the API is only about “successful” responses: article lists, cards, status 201 Created with Location. But a real client most often suffers, in the good sense, not from your DTOs, but from your errors. And if the errors are chaotic, the client gets hurt: the frontend does not understand what to show the user, the mobile app cannot localise the message, and in your logs you see “something crashed somewhere” and start guessing what exactly happened.

A unified error contract is an agreement: “For any error, I will return JSON in a clear shape. It will include the status, a human-readable title, a detail, a machine-readable errorCode, and if the problem is in fields, a list of violations.” Such a contract makes the system predictable. And for testing, that is gold: you can not only check that an error happened, but also verify that it always turns into the same response format, regardless of where it originated.

To make it easier to keep in mind what we actually want to see, it is useful to fix a reference point in the form of a table (not as a strict law of the universe, but as a working map for ContentHub):

Source of the problem Example exception Expected status Example errorCode violations
Input data error (valid JSON, but DTO rules are violated) MethodArgumentNotValid Exception 400 VALIDATION_FAILED yes
The request body cannot be read (broken JSON) HttpMessageNotReadable Exception 400 MALFORMED_JSON usually no
Resource not found ArticleNotFound Exception 404 ARTICLE_NOT_FOUND no
Resource state conflict InvalidStatusTransition Exception 409 INVALID_STATUS_TRANSITION no
Internal failure Exception 500 INTERNAL_ERROR no

The point of this table is simple: even if the details change, the shape of the error must remain stable. And that is where we move on to Spring mechanics.

2. The error pipeline in Spring MVC

To make @ControllerAdvice stop sounding like “a magical annotation for shamans,” it helps to see where exactly errors are born in MVC. The controller is not the only place where something can go wrong. Before the controller, there is body reading, parameter conversion, and binding. After the controller, there is response serialization. And if an exception is thrown inside a delegated service, that too must become a normal HTTP response, not “oops, stack trace in production.”

If we simplify the request life cycle into a teaching diagram, it looks roughly like this:

flowchart TD
    A["HTTP request"] --> B["Message converters / JSON reading"]
    B -->|success| C["Binding: assembling DTO"]
    B -->|error| X["HttpMessageNotReadableException"]

    C --> D["Bean Validation @Valid"]
    D -->|error| Y["MethodArgumentNotValidException"]
    D -->|success| E["Controller method"]

    E --> F["Service"]
    F -->|business error| Z1["ArticleNotFoundException / InvalidStatusTransitionException"]
    F -->|unexpected| Z2["RuntimeException"]

    X --> G["@ControllerAdvice"]
    Y --> G
    Z1 --> G
    Z2 --> G

    G --> H["ApiProblem JSON + status"]

The important thing in this diagram is the following: @ControllerAdvice stands “on the side” of the entire pipeline. It does not replace validation, does not fix broken JSON, and does not repair business logic. It does a more pragmatic thing: it takes an exception and turns it into a predictable HTTP response.

And if we want to test the web layer as the API boundary, we need to know how to “highlight” this translation of an exception into ApiProblem in tests.

3. try/catch in a controller as an anti-pattern

At the start of a project, the temptation is very simple: “Well, I can just do try/catch directly in the controller, and everything will be fine.” And that really will work for some time. But then you will have a second controller, then a third, then new errors will be added, then you will want to change the response format... and you will end up in a world of copy-paste, where one place was forgotten and the client gets two different error formats depending on the endpoint. That is a quiet, polite kind of chaos.

Look at a typical anti-pattern. It even looks “neat,” but it is the kind of neatness that says “while the project is small”:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class EditorArticleController {

    @PostMapping("/api/editor/articles/42/submit")
    ResponseEntity
   submit() {
        try {
            // Bad: the controller itself decides how to map errors to an HTTP response.
            // Because of that, the error format spreads across the project.
            return ResponseEntity.ok().build();
        } catch (RuntimeException ex) {
            // Bad: here we lose the unified contract (no ApiProblem, no errorCode, no instance).
            return ResponseEntity.status(500).build();
        }
    }
}

The problem here is not the catch. The problem is that the controller starts being responsible for things it should not be responsible for: the error format and exception mapping. In a good world, the controller is thin: it accepts HTTP, validates, delegates, and returns a result. Exception translation is a separate responsibility.

And the most unpleasant thing for testing is that in every test you have to think, “Does this controller catch exceptions itself or not?” and you lose a single place that you could check once and trust afterwards.

4. @ControllerAdvice as a single umbrella

Once you see that errors come from different places, a natural question arises: “Where do we put one place that normalises all of this?” In Spring MVC, the answer is exactly this: @ControllerAdvice. It is a component that hooks into the MVC infrastructure and lets you centrally handle exceptions, add attributes to the model (for MVC pages), and generally influence web-layer behaviour. In our API layer, the key scenario is exception handling.

For REST APIs, people most often use @RestControllerAdvice. This is effectively @ControllerAdvice + automatic @ResponseBody for the returned object. In other words, you return ApiProblem, and Spring serializes it into JSON (through Jackson 3), like a regular response DTO.

The minimal skeleton looks like this:

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

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(Exception.class)
    void handleAny() {
        // Important: this is a fallback handler for "everything unknown".
        // In real code this is usually ResponseEntity<ApiProblem> + logging.
    }
}

By itself, this skeleton does not do anything useful yet, but it establishes an important architectural rule: any controller can “just throw an exception,” and the error handler will turn it into a stable response. And that is convenient for tests: in @WebMvcTest we can mock the service and force it to throw the needed exception without modelling the whole world.

5. @ExceptionHandler: translating into ApiProblem

When we write @ExceptionHandler, our goal is not to “hide the error,” but to translate it into the client’s language. It is like an interpreter at an international conference: participants speak different languages (different exception types), but the client always wants to hear one language (our ApiProblem). And yes, the interpreter must be consistent.

Let us start with simple business exceptions. Suppose we have ArticleNotFoundException, which should become 404, and InvalidStatusTransitionException, which should become 409. Inside the handler, we build an ApiProblem and return a ResponseEntity with the required status.

To keep the example compact, let us show only the essential part:

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(ArticleNotFoundException.class)
ResponseEntity<ApiProblem> handleNotFound(ArticleNotFoundException ex, HttpServletRequest request) {
    // instance is convenient to fill with the request URL: it helps both the client and the logs.
    String instance = request.getRequestURI();

    // Important: a factory method hides the details of filling ApiProblem and reduces copy-paste.
    ApiProblem problem = ApiProblem.notFound(instance, ex.getMessage());

    // The HTTP status must match the semantics of the error.
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}

Here we already see several useful ideas. First, instance can be filled from request.getRequestURI(), so the client understands which exact endpoint produced the error (this is useful for both debugging and logs). Second, it is better to move the creation of ApiProblem into a factory method (notFound, conflict, badRequest) — this way you avoid copy-pasting the same fields throughout the advice.

Technical exceptions must also go into this umbrella. For example, malformed JSON, which in Spring often appears as HttpMessageNotReadableException. We already saw it in the previous lecture, but now our job is to make sure that it turns not into a “default Spring error,” but into our ApiProblem:

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ApiProblem> handleMalformedJson(HttpMessageNotReadableException ex, HttpServletRequest request) {
    // The important part here is not to return internals (for example, part of the stack trace) in detail.
    // The client only needs to understand: the JSON cannot be read.
    ApiProblem problem = ApiProblem.badRequest(
        request.getRequestURI(),
        "Malformed JSON",
        "MALFORMED_JSON"
    );

    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem);
}

And finally, validation failure (MethodArgumentNotValidException) is a separate class of errors, where the client needs to see violations. We will not repeat all of Bean Validation (that was in lecture 1), but we will show an important piece: violations are formed from FieldError in BindingResult.

import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiProblem> handleValidation(MethodArgumentNotValidException ex) {
    // Turn Spring validation errors into our stable violations format.
    List<Violation> violations = ex.getBindingResult().getFieldErrors().stream()
        // field = DTO field name, message = human-readable explanation
        .map(fe -> new Violation(fe.getField(), fe.getDefaultMessage()))
        .toList();

    // Important: the "validation failed" contract differs from "malformed JSON".
    return ResponseEntity.badRequest().body(ApiProblem.validationFailed(violations));
}

Notice how “tasty” this contract looks here: the client receives structured violations, not one long text like “Validation failed for object…”. And that is exactly what we want to lock down with tests.

6. ApiProblem: what to stabilise with tests

The problem with an error DTO is the same as with any DTO: you can make it too loose (and then the client has a hard time), or too “concrete” (and then any change becomes pain). You need balance here. We want ApiProblem to be stable enough for the client to rely on it, but flexible enough so that you can change wording without breaking the world.

A minimal ApiProblem shape for ContentHub might look roughly like this (not as the “only correct” one, but as a clear reference point):

import java.util.List;

public record ApiProblem(
    int status,
    String title,
    String detail,
    String instance,
    String errorCode,
    List<Violation> violations
) { }

And a violation is a simple “field + message” pair:

public record Violation(String field, String message) { }

What is usually worth stabilising with tests here? Status and errorCode — almost always yes, because the client builds logic on top of them. The presence or absence of violations is also yes, because the frontend will render form errors. But detail is often better checked not fully, but partially: sometimes the exception message changes, and you do not want tests failing because of one extra word.

So the practical testing rule is: test the meaning, not the literature. Key fields and structure — yes. Full message text — only if it is really part of your contract.

7. @WebMvcTest and importing the advice

There is a subtle but important point with @WebMvcTest. This slice starts the MVC infrastructure and your controller, but it does not have to automatically pull in your full “production” set of components. And if your @ControllerAdvice somehow did not end up in the test context, you will start checking Spring/Boot default behaviour: here there will be a standard response, there an HTML error page, elsewhere another ProblemDetail format. And you will look at that and think: “Strange, but the test is green...” — yes, green, because it tested the wrong contract.

That is why a good habit in web-slice tests is this: if you want to verify the translation of exceptions into ApiProblem, import the advice explicitly. The most direct way is @Import.

Example test skeleton:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;

@WebMvcTest(EditorArticleController.class) // Start only the MVC layer and the specific controller
@Import(ApiExceptionHandler.class)         // Explicitly add our error handling (advice)
class EditorArticleControllerErrorWebMvcTest { }

This way you honestly say to yourself (and the team): “In this test I am checking controller behaviour together with our error handling.” And that is exactly what we need.

8. Testing business exception mapping

Now for the nicest part: what this looks like in a test. The key idea is that we do not want to model business logic inside the controller test. We want to verify the web contract: if the service says “status conflict,” then the HTTP response must become 409 and have errorCode = INVALID_STATUS_TRANSITION.

So we do it simply: mock the service, given(...).willThrow(...), send a request through MockMvc, and assert the status and JSON.

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(EditorArticleController.class)
@Import(ApiExceptionHandler.class)
class EditorArticleControllerErrorWebMvcTest {

    @Autowired MockMvc mvc; // Tool for executing HTTP requests against the MVC layer in tests
    @org.springframework.test.context.bean.override.mockito.MockitoBean
    EditorArticleService service; // Mock the service to control which exceptions "fly out"

    @Test
    void shouldTranslateConflictToApiProblem() throws Exception {
        // Agree with the mock service: for submit on article 42 it throws a business exception
        given(service.submitForReview(42L))
            .willThrow(new InvalidStatusTransitionException("Already published"));

        // Check it through the client's eyes: status + key ApiProblem fields
        mvc.perform(post("/api/editor/articles/42/submit"))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.status").value(409))
            .andExpect(jsonPath("$.errorCode").value("INVALID_STATUS_TRANSITION"));
    }
}

Something very “correct” is happening here. The test does not know why the status transition is invalid. It does not repeat the logic of PublicationPolicy. It checks the HTTP boundary: which status and which payload the client sees. And that is exactly what makes @WebMvcTest valuable — it protects the API contract, not the business rules.

You can do the same for 404 if the service throws ArticleNotFoundException. And that will be a very honest web-layer test: the request is valid, but the resource does not exist, and the advice must translate that into a unified contract.

9. Technical errors: malformed JSON and validation

In real life, clients break requests in two ways: sometimes “correctly but incorrectly” (valid JSON, but empty title), and sometimes “incorrect at the syntax level” (broken JSON). We already split these two branches of the pipeline: in one case the DTO manages to be assembled and validated, in the other it does not. Now the goal is different: both cases must become ApiProblem, but with different semantic fields.

Malformed JSON is tested simply: we send a broken body. The controller does not even have time to start, but the advice must still return our format.

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

@Test
void shouldReturnApiProblemWhenJsonIsMalformed() throws Exception {
    // Important: the JSON is syntactically broken here (the closing brace is missing).
    String body = "{\"title\":\"A\",\"summary\":\"B\",\"body\":\"C\",\"category\":\"JAVA\"";

    // The controller will not be called — request body reading will fail. But the error contract must still hold.
    mvc.perform(post("/api/editor/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errorCode").value("MALFORMED_JSON"));
}

Validation failure is a similar status case (400), but different in structure: here there must be violations. We do not have to check the whole list in every test, but it is useful to check that violations exist at all and that they contain the required field.

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

@Test
void shouldReturnViolationsWhenTitleIsBlank() throws Exception {
    // JSON is valid, but the title value does not pass @NotBlank/@NotEmpty (or an equivalent check).
    String body = "{\"title\":\" \",\"summary\":\"S\",\"body\":\"B\",\"category\":\"JAVA\"}";

    // What matters to us is not the exception name, but that the client receives violations and can highlight the field.
    mvc.perform(post("/api/editor/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.violations[0].field").exists());
}

Notice an important psychological detail: the client does not care that this is MethodArgumentNotValidException. The client cares: “Can I use violations to highlight fields?” And that is exactly what we are locking down.

Engineering nuance: test mapping, not exception details

When you start writing error tests actively, it is very easy to fall into “retesting Spring.” For example, trying to verify that malformed JSON throws exactly HttpMessageNotReadableException, or that validation fails with exactly that class. Usually that has little value: these details belong to the framework and may change between versions (and we are, as a reminder, on the modern Spring 7 / Boot 4 stack).

A much stronger test looks like this: “for this request, the output is this ApiProblem.” That is a check of your contract. And it will survive both a Jackson version change and internal Spring MVC reshuffling (as long as the contract stays the same). Of course, if you intentionally tied yourself to a specific exception type in the advice, fine, that was your conscious choice. But in tests it is better to look with the client’s eyes, not with the eyes of ExceptionResolver.

And here it is also useful to keep one more limit in mind: controller tests should not turn into a catalogue of all possible errors. They should cover the key classes of errors and confirm that your advice is stable. A unified error format is already enough to make the client comfortable reading the response, but for the full contract that is still not enough: the same ApiProblem must also come with the correct HTTP status.

10. Typical mistakes: advice and tests

Mistake #1: the advice is written, but in @WebMvcTest it is not imported.
The tests start checking Spring/Boot’s default error format, and it looks like “everything works.” Then you run the application, get a different payload, and the client breaks. This is very easy to fix: in web-slice tests where you verify the error contract, import the advice explicitly via @Import and do not hope that “it will get picked up somehow.”

Mistake #2: controllers catch exceptions manually, and the advice becomes useless.
If there is a try/catch inside the controller and it returns a response formed “somehow,” then @ControllerAdvice simply will not see the problem. As a result, errors go through advice in one endpoint and through manual code in another, and the API stops being uniform. It is better to keep controllers thin and allow exceptions to “fly out” so that advice can translate them.

Mistake #3: all errors are dumped into one handler on Exception.class, and the meaning of statuses and errorCode is lost.
Yes, technically you can make everything “always 500” with one handler. But then the client will not distinguish “article not found” from “the server crashed.” And most importantly, you will disable your own ability to write precise negative tests. It is better to have several handlers for the main classes of errors: not found, conflict, validation, unreadable body, and only at the end a fallback for unexpected cases.

Mistake #4: internal details leaked into the error response (stacktrace, class names, SQL).
Beginners want to send detail as ex.toString() — “let the client see it.” In practice, that is insecure, ugly, and turns your API into an “open book of internal devices.” In detail, it is better to keep a safe, controlled wording, and leave the details in logs. In tests, you can fix that the errorCode is correct, and that detail does not contain anything obviously unnecessary.

Mistake #5: tests check the full detail text and become brittle.
If you compare detail in full in a test, any text change (even a useful one) breaks the suite. More often it is reasonable to check semantic fields (status, errorCode, presence of violations) and, if needed, only part of detail (or not test the text at all). The exception is when the text really is part of the contract and the client uses it as-is.

1
Task
Spring Test, level 12, lesson 2
Locked
Unified `ApiProblem` for a missing resource
Unified `ApiProblem` for a missing resource
1
Task
Spring Test, level 12, lesson 2
Locked
`ApiProblem` for JSON read errors
`ApiProblem` for JSON read errors
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION