1. The desire to return an exception and its consequences
When you are just starting to write an API, the most natural reaction to a failure is to show the client what is “already there”: the exception, its message, and, if you are lucky, the stack trace. It seems faster: the front-end developer will immediately see where the error is and stop asking questions. Alas, this is like treating a headache with a guillotine: the problem really does disappear... along with the patient.
In practice, “returning the exception as is” breaks three things at once. First, you start exposing the server's internals, and that is not just ugly — it is sometimes dangerous. Second, you make the public contract unstable: you changed a class, updated a library, the exception text changed — and the client suddenly “broke” because it (heaven forbid) parsed the message as a string. Third, you give the person (and the client code) noise instead of meaning: the client needs to understand what happened to the request, not read a mini-novel titled “How the JVM Was Sad in Three Volumes.”
Error contract and internal diagnostics
To stop getting confused, it helps to mentally split the world into two recipients of the same unpleasant event. The first recipient is the API client (a browser, a mobile app, another service). It needs compact, stable, and clear information: which status, what went wrong, what can be fixed. The second recipient is you and your team. You need diagnostics: stack traces, exception types, context, so you can fix bugs.
These recipients are not just different — they have different tasks, and therefore you must not give them the same data.
Here is a good picture to keep this in mind:
flowchart TD
%% Two branches: one for internal diagnostics, another for the public response to the client
A[HTTP request] --> B[Controller / service]
B -->|Error| C[Exception in code]
C --> D[Internal diagnostics: logs, stack trace]
C --> E[Public response: status + clear description]
Notice that we are not discussing how exactly to technically build a single error handler at the application level — that will be the next step. Right now, what matters is something else: even if you temporarily handle an error “manually” in one place, the data for branch D and the data for branch E must be different.
2. Stack trace and its place in an API response
A stack trace is a detailed list of “which methods were called while everything was crashing.” For a developer, this is golden information: you can see exactly where the error occurred, which classes were involved, which libraries were in the chain. But for a client, a stack trace is almost always garbage: it is long, multiline, contains internal class and package names, and sometimes file paths, line numbers, and fragments of technical messages.
Here is a typical anti-example in the style of “well, at least it is informative”:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DemoController {
@ExceptionHandler(Exception.class)
ResponseEntity<String> handle(Exception ex) {
// Bad: we are giving the client internal server details (exception type/message).
// That does not help the client, but it does reveal the implementation.
return ResponseEntity.status(500).body(ex.toString());
}
}
Even if you return ex.toString() here, you are already exposing the exception type. And if you go further and add ex.getStackTrace() (or serialize the entire Exception), you will end up with a huge “blanket” in the response. In the best case, it is just ugly. In the worst case, you hand an attacker a map of internal technologies and hints about where to “poke” next.
3. Internal class and package names
Even if you do not return the full stack trace, many people fall for the “harmless” idea of returning ex.getClass().getName() or at least ex.getClass().getSimpleName() to the client. It seems honest: the client should know what broke. But in reality, this is again a mix of two worlds: the exception class is an implementation detail, and the public contract should describe the error in API language.
Imagine that the client got this:
{
"error": "com.example.tasktracker.domain.exception.TaskNotFoundException"
}
For an API client, that does not add any meaning. They still do not know your code and are not required to know your packages. But now you can no longer safely rename a package, move exceptions into another module, or change the exception hierarchy — formally, you changed the external contract (even if you did not mean to). And yes, it looks strange: as if a restaurant said Kitchen.StockService.NoMoreSoupException instead of “we ran out of soup.”
So the rule is simple: Java class names are for internal diagnostics; they belong in logs, not in a JSON response to the client.
4. Raw exception messages: ex.getMessage()
Now for the trickiest point: ex.getMessage(). It seems “human-readable” because it is a string. But precisely because of this “simplicity,” people most often start handing it out blindly. The problem is that an exception message is almost never a stable, client-oriented formulation.
First, messages are often purely technical and meant for developers. A NullPointerException may not have a message at all, or it may have a message that depends on the JDK version. A DateTimeParseException may produce text where half of it is parsing details. A HttpMessageNotReadableException (which often occurs with broken JSON) may contain Jackson's internal machinery. If you show that to the client as is, you get something like: Cannot deserialize value of type ... from String .... That is not your API language. That is library language.
Second, messages are unstable. They change between library and framework versions. Today you updated Spring Boot or Jackson — and the text changed. If someone on the client side (or in the documentation) relied on a specific wording, you got a “hidden breaking change.” The worst part is that these bugs are very unpleasant to investigate: the server is working, the status is correct, but the front end “somehow does not recognize the error.”
Third, an exception message sometimes contains a piece of user input. That can be fine and useful, but without control you can easily inject garbage into logs or produce “dirty” text in the response. A client may accidentally or deliberately send a string that looks like HTML, like SQL, like anything at all. Returning that back in the response is a bad idea, especially if someone later displays it in the UI without escaping.
If you want a short formula: ex.getMessage() is a diagnostic hint, not a public contract.
5. Clear errors in API language
If we do not give the client a stack trace, class names, or raw messages, what remains? The most useful thing remains: a clear description of the problem in the terms of your API. In the context of Task Tracker API, the client thinks like this: “I am creating a task,” “I am getting a task by id,” “I am trying to change the status,” “I am attaching a file.” That means the errors should also be described in that language.
At a minimum, you usually need three things. The client needs a status (it is already in HTTP), a short title (“what happened”), and a detail (“what exactly is wrong”). Even if you are not using a standard error format yet (we will get to that soon), it is important to be able to formulate controlled text that:
1) does not depend on internal classes,
2) does not “jump” with library versions,
3) does not leak extra details.
An important note right away: this record is not a format worth locking in as the main one for the whole API. We only need it here as a temporary container to separate controlled public text from internal diagnostics.
Example of the simplest “public” model:
// Public error model: only what we consciously want to show the client.
public record PublicError(String title, String detail) {
}
And an example of how a not-found scenario can be described in API language:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
ResponseEntity<PublicError> notFound(String taskId) {
// Good: we form the text as part of the API contract, rather than taking it from an exception.
PublicError body = new PublicError(
"Task not found",
// You can carefully include user input in the detail if you control the format.
"Task with id '%s' was not found".formatted(taskId)
);
// Good: we return a semantically correct HTTP status.
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
Notice that we do not use ex.getMessage() here. We build the text as part of the contract. Yes, that is a little more work. But it is the kind of work that makes an API mature.
This container is enough to feel the principle. But once there are many such responses, your record quickly turns into yet another homemade mini-standard. That means that what you need here is not another PublicError, but one shared error body format for the whole API.
6. Details belong in logs
If you cannot give the client internal diagnostics, a logical question arises: “Then where should we keep it so we can fix errors ourselves?” The answer is simple and boring (and therefore correct): in logs. Logs are the place where stack traces, internal classes, and any implementation details are appropriate.
To begin with, a basic approach is enough: when a technical error occurs, log it with the exception, and give a controlled description outward. In the service layer, it looks roughly like this:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TaskService {
private static final Logger log = LoggerFactory.getLogger(TaskService.class);
public void doSomethingRisky() {
try {
riskyOperation();
} catch (RuntimeException ex) {
// In logs, you can (and should) keep the stack trace: this is internal diagnostics.
log.error("Unexpected technical error in TaskService", ex);
// Here we do not "turn the error into null" and we do not replace it.
// Let the layer above (for example, an exception handler) decide what to give the client.
throw ex;
}
}
private void riskyOperation() {
// This could be work with a database, an external service, a file, etc.
}
}
The idea itself matters here. We do not “hide” the error, pretend that everything is fine, or turn the failure into a mysterious null. We honestly record diagnostics where they belong (in logs) and do not drag them into the public API.
And one more important thought: logging also needs common sense. Do not log the entire request body for every request (especially if it may contain personal data). Do not log huge objects. Logs are not a trash can, they are a diagnostic tool. But that is already a subtle engineering-tuning topic; today we care about the foundation: diagnostics stay inside the server.
7. A mini-example in Task Tracker API
To tie this to our project, let us take the scenario GET /api/v1/tasks/{taskId}. Suppose the service throws a TaskNotFoundException if the task does not exist. The worst way to handle this is to catch the exception and return everything outward.
Anti-example:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
public class TaskController {
@GetMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> getById(@PathVariable String taskId) {
try {
Object task = findTask(taskId);
return ResponseEntity.ok(task);
} catch (Exception ex) {
// Bad: we are returning "type + message" from the exception.
// This is unstable and may leak internal details (database, tables, technologies).
return ResponseEntity.status(500).body(ex.toString());
}
}
private Object findTask(String taskId) {
// An example of a technical detail that should never end up in an API response.
throw new RuntimeException("DB is down, table TASKS not found");
}
}
There are several problems here, but for our lecture the key one is that you return ex.toString() outward, that is, “type + message.” And the message may contain anything, including internal details. Plus you return 500 for everything, even if the problem is not on the server, but simply that the task does not exist.
Now let us make it better, but keep in mind the limitation: the PublicError below is still a temporary container. The point of the example is not to lock in your own error format, but to avoid dragging ex.toString() outward. We form a controlled body and return a clear status. Diagnostics, if needed, are logged.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
public class TaskController {
private static final Logger log = LoggerFactory.getLogger(TaskController.class);
public ResponseEntity<?> getById(String taskId) {
try {
Object task = findTask(taskId);
return ResponseEntity.ok(task);
} catch (TaskNotFoundException ex) {
// Good: domain/business error -> clear status and clear text for the client.
// We do not use ex.getMessage() and do not expose the exception class name.
PublicError body = new PublicError(
"Task not found",
"Task with id '%s' was not found".formatted(taskId)
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
} catch (Exception ex) {
// Good: technical error -> details in logs.
// {} is a placeholder for taskId, ex is passed separately to get a stack trace in logs.
log.error("Technical error while getting task {}", taskId, ex);
// Outwardly, we give controlled text without internal details.
PublicError body = new PublicError(
"Internal error",
"Unexpected error while processing request"
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
private Object findTask(String taskId) {
// For the sake of the example: the domain error "task not found" is represented by a separate exception.
throw new TaskNotFoundException(taskId);
}
}
Architecturally, this is a transitional variant, but it clearly shows the main point. The client gets a clear message in API language, while the server keeps the details for itself. And once such a PublicError starts repeating across controllers, it becomes clear that you need one standard error body format, not a separate record for every case.
8. Typical mistakes when responding to errors
When you start “cleaning up” error responses, the first problems usually appear not because you do not know Spring, but because of human habits. It is very tempting to think “so the client understands it faster,” “so the front end does not get distracted,” “so the tests are easier to write.” These are understandable desires, but they often lead to the API becoming a dumping ground for internal information.
Mistake #1: returning stackTrace “only on dev,” and then forgetting to turn it off.
This happens more often than people want to admit. First you make a “convenient debug mode,” then you get a second profile, then a third, then staging, then “oops, why does the client in production have a path like /home/jenkins/workspace/... in the response?” If you build an API as a contract, it is better to get used to this immediately: the stack trace lives in logs, not in the response.
Mistake #2: thinking that the exception name is an “error code.”
A class name seems like a good identifier, but it is an identifier for Java code, not for an external contract. It changes during refactoring and carries internal details. A client-side “error code” (if you need one) should be a separate entity, independent of packages and classes.
Mistake #3: returning ex.getMessage() as if it were text for the user.
Exception messages are rarely phrased “for humans” and even more rarely stay stable. Plus, they can change between library versions. Even if a specific message looks decent today, tomorrow you update a dependency — and you get a new wording that breaks the client's UX or tests.
Mistake #4: mixing public meaning and internal diagnostics in one field.
You often see responses like: “Task not found. NullPointerException at TaskRepository.findById(TaskRepository.java:42)”. This is an attempt to please everyone at once, but it usually turns out badly. The client does not need the line number in the repository, and the developer does not need a fairy tale in the style of “task not found” without context. Separate them: public meaning in the response, internal diagnostics in the logs.
Mistake #5: making the error text too generic so that “nothing leaks.”
The opposite extreme is to return Error or Something went wrong for everything. Formally, you leaked nothing, but the contract became useless. The client needs at least clear categories in API language: Task not found, Invalid input, Operation is not allowed. Without this, the client cannot react properly, and it is hard for the user to fix the request.
GO TO FULL VERSION