1. Roles of codes and messages
When you build an API, you suddenly become a provider of a “language” for other programs. And programs are nervous creatures: they do not handle it well when the meaning of a response is hardcoded into text like The title field is required. A person will understand it anyway, but client-side code will start to suffer: parsing strings, searching for substrings, comparing text as a whole. It is roughly like determining the weather from a neighbor’s phrase it is kind of fresh today — it works right up until the first neighbor who likes drama.
path already answers the question “where exactly did the input break”. Now we need to separate two more questions: what client code can safely rely on and what we show to a person visually.
From a technical point of view, we already have the HTTP status 400 Bad Request. But the status is too broad: it says “the client sent something wrong”, but it does not say what exactly. That is why we need additional semantics inside the response body. This is where codes appear: a top-level code for the “class of problem” and a detail code for “which rule was violated”. And message remains text for a human, not for logic.
To see the difference clearly, it is convenient to keep this “role matrix” in mind:
| Response element | Which question does it answer | Example | Who is it primarily for | Should it be stable? |
|---|---|---|---|---|
| HTTP status | “Is this success or an error, and of what class?” | 400 | for infrastructure, client, debugging | yes |
| Top-level code | “What class of problems is this overall?” | INVALID_INPUT | for client logic and analytics | yes |
| path | “Where exactly is the problem?” | tags[1] | for UI and precise display | yes |
| Detail code | “Which rule was violated?” | REQUIRED | for client logic | yes |
| message | “How do I explain this to a person?” | “The title field is required” | for a person | no (may change) |
And yes, this is not bureaucracy. It is a way to make the response useful to both people and programs, and to keep your contract from breaking just because tomorrow you wanted to rewrite the message text in a friendlier way.
2. Top-level response code
A top-level code is like a label on a moving box: “KITCHEN”, “OFFICE”, “FRAGILE”. Inside the box there may be different things, but the label is needed so that you can immediately understand the category. For validation errors, one honest label is usually enough: INVALID_INPUT.
Because 400 means “something is generally wrong with the request”, while INVALID_INPUT means “the input data failed validation”. Other problems can also live inside 400, but right now we are designing specifically validation scenarios and not mixing them with others.
For our Task Tracker API, this means a simple rule: all validation failures, regardless of whether they come from body, query, or path, receive a single top-level code INVALID_INPUT. The details will differ: source, path, detail code, and message.
There is a subtle but very important idea here: the top-level code must not grow “for every endpoint”. A variant like INVALID_TASK_CREATE_REQUEST or INVALID_TASK_SEARCH_CRITERIA looks convenient for the first two days, and then turns into a hundred-page dictionary. The client does not need a separate code for every endpoint — it needs a class of problem, after which it will look in errors[] to see what exactly broke.
In a project, it is convenient to fix top-level codes as constants so you do not generate typos like INVALD_INPUT (that happens too — and yes, it is a separate kind of pain).
package com.example.tasktracker.api.dto.error;
public final class ApiErrorCodes {
// Stable top-level codes are part of the API contract.
// They must not be "quietly renamed", unlike message text.
public static final String INVALID_INPUT = "INVALID_INPUT";
// Utility class: constants only, no instances.
private ApiErrorCodes() {
}
}
This is a small step, but it enforces discipline: the code becomes part of the contract, not a random string that happened to be typed.
3. Detail code
If the top-level code is the “label on the box”, then the detail code is the “type of item inside the box”: “PLATE”, “MUG”, “KNIFE”. It answers the question: which specific rule was violated. And this is exactly what the client will rely on in its logic: highlight the field, show the correct kind of error, maybe translate the message into another language or replace it with our “human-friendly” text.
There are three rules here that save an API from chaos.
First: the detail code must be machine-readable, short, and stable. This is usually UPPER_SNAKE_CASE or simply UPPER_CASE for short codes like REQUIRED. No localization inside the code. If you make the code REQUIRED, you may win the dubious contest for “the most Russian API”, but you will lose everything else: clients, documentation, consistency, and integrations.
Second: the detail code must not duplicate path. If path = title, then the code TITLE_REQUIRED is redundant. It makes the code dictionary 10 times larger with no real benefit. Much simpler: title + REQUIRED already fully defines the meaning.
Third: the detail code must not leak internal mechanisms. It is very easy to take what Bean Validation or Spring gives you and “pass it through to the outside”. For example, NotBlank, Size, Pattern. But these are the internal names of your constraints and the specific technology. Today it is Bean Validation, tomorrow you changed the annotation or provider, and the client contract suddenly got “renamed”. The client does not need to know that inside you use @Size(max = 80) — the client needs to understand that the field is “too long”.
That is why we design our own detail codes. For our course and project, it is convenient to keep a small dictionary. A code dictionary that is too large is almost always a symptom that you are trying to build an “ideal classification of all errors in the world”, and that usually ends with philosophy and endless PR arguments.
Here is a practical mapping table of “which problem → which detail code”, which works well for ordinary REST APIs:
| Type of violation | detail code | Example message | Example where it appears |
|---|---|---|---|
| Field is required | REQUIRED | “The title field is required” | @NotNull, @NotBlank |
| Too short | TOO_SHORT | “title must be at least 3 characters long” | @Size(min=...) |
| Too long | TOO_LONG | “assigneeName must not be longer than 80 characters” | @Size(max=...) |
| Too small | TOO_SMALL | “size must be at least 1” | @Min |
| Too large | TOO_LARGE | “size must be at most 100” | @Max |
| Invalid format | INVALID_FORMAT | “Email has an invalid format” | @Pattern |
| Invalid value | INVALID_VALUE | “priority has an invalid value” | custom constraint |
| Duplicate in collection | DUPLICATE | “Tags must be unique” | custom collection rule |
| Invalid range | INVALID_RANGE | “dueAfter must be earlier than dueBefore” | cross-field rule |
Please note: this is a “dictionary of meanings”, not a “dictionary of annotations”. The meaning is what matters to the client.
In a project, it is convenient to keep such a dictionary in one enum ValidationDetailCode, and expose name() externally. Then the codes do not spread through string literals, and they are easier to keep stable from endpoint to endpoint.
Yes, there are a few codes here — intentionally. For a learning project, that is a plus: you will learn faster how to maintain contract discipline instead of collecting rare categories.
And now the important part about “why this is useful to the client at all.” Client code can react to the detail code without parsing the text. Even if it is not a frontend, but tests, an SDK, or some integration layer.
// Example: choose the UI/client reaction by code, not by message text.
String uiAction = switch (detail.code()) {
case "REQUIRED", "INVALID_FORMAT" -> "show-field-error";
case "INVALID_RANGE" -> "show-group-error";
default -> "show-generic-error";
};
It looks simple, but this is where engineering maturity happens: logic does not depend on the language of messages and does not break when you rewrite message.
4. Message for a human
A message (message) is an “explanation for a human”. It matters because people read errors with their eyes, not with JSONPath. But the message must not become the anchor that client code clings to. If you have ever seen a frontend that does if(message.contains("required")), you know that somewhere in the world, one architect just cried.
The ideal role of message in our contract is this: it is a good fallback that can be shown to the user “as is” if the client did not want or could not localize the error. Classification, however, should go through code.
What does a “good message” mean? It should be short, specific, and not leak internal details. The message Validation failed is too generic. The message javax.validation.ConstraintViolationException at ... is not even a message anymore, but a cry of despair and a leak of implementation details. A message should answer the simple human question: “What should I fix?”
There is another nuance: message may contain numbers and parameters (80, 100, 3..120). That is normal. On the contrary, it is useful: the person immediately understands the limit. But that is exactly why message is a poor fit for programmatic processing. Today it is 80, tomorrow you change the rule to 100, and the “logic by text” breaks. The code stays the same (TOO_LONG), while the message changes — and that is correct.
An example from our domain:
- path: assigneeName
- code: TOO_LONG
- message: assigneeName must not be longer than 80 characters
If tomorrow the business says let’s make it 120, you change only the message, and the client code still understands: this is an error of class TOO_LONG.
5. JSON contract of Task Tracker API
When we put everything together, we get a response that is both “readable by eye” and “processable by code”. Below is an example in the style of our project. Note: the top-level code is the same, while the real useful information is inside errors.
For a task create scenario where title is empty and tags are duplicated:
{
"status": 400,
"code": "INVALID_INPUT",
"summary": "Input validation failed",
"errors": [
{
"source": "body",
"path": "title",
"code": "REQUIRED",
"message": "The title field is required"
},
{
"source": "body",
"path": "tags[1]",
"code": "DUPLICATE",
"message": "Tags must be unique"
}
]
}
For a search scenario where size is too large and the date range is invalid:
{
"status": 400,
"code": "INVALID_INPUT",
"summary": "Input validation failed",
"errors": [
{
"source": "query",
"path": "size",
"code": "TOO_LARGE",
"message": "size must be at most 100"
},
{
"source": "query",
"path": "dateRange",
"code": "INVALID_RANGE",
"message": "dueAfter must be earlier than dueBefore"
}
]
}
If you like diagrams, you can remember the relationship between elements like this:
flowchart TD
A["HTTP 400"] --> B["code = INVALID_INPUT"]
B --> C["errors[]"]
C --> D["source + path (where)"]
C --> E["detail code (what)"]
C --> F["message (how to explain it to a person)"]
This diagram is useful because there is no magic in it: each piece answers one question and does not intrude on another role.
6. Implementation in Java: codes and mapping
The response DTOs themselves and the top-level INVALID_INPUT are already fixed for us. What matters more here is how to translate Spring/Bean Validation internal signals into our dictionary of codes and avoid leaking NotBlank, Size, or Pattern as part of the public contract.
Let us assume that the dictionary is already defined as the ValidationDetailCode enum. The mapping below is intentionally compact: the important part is the principle itself, “technical framework code → our stable code”. For constraints like Size, in a real project you would also usually check whether we hit min or max.
import org.springframework.validation.FieldError;
static ValidationDetailCode toDetailCode(FieldError error) {
// Map internal/technical Spring/Bean Validation codes to stable API codes.
// Important: do not expose "Size"/"NotBlank" externally — they are implementation details, not the contract.
return switch (error.getCode()) {
case "NotNull", "NotBlank" -> ValidationDetailCode.REQUIRED;
case "Size" -> ValidationDetailCode.TOO_LONG;
case "Min", "Positive", "PositiveOrZero" -> ValidationDetailCode.TOO_SMALL;
case "Max" -> ValidationDetailCode.TOO_LARGE;
case "Pattern", "Email" -> ValidationDetailCode.INVALID_FORMAT;
default -> ValidationDetailCode.INVALID_VALUE;
};
}
You can also build one detail from a FieldError very compactly:
import org.springframework.validation.FieldError;
static ValidationErrorDetail toDetail(FieldError error) {
// Turn an internal binding error into a detail of our public contract.
return new ValidationErrorDetail(
"body",
error.getField(),
toDetailCode(error).name(),
error.getDefaultMessage()
);
}
There is another nuance here that is important to remember. error.getField() returns the field name from the perspective of Java/binding, while our API promised that path lives in the logic of public names. If you have aliases or renamed fields in DTOs, you need to make sure that path matches the external name. In a learning project, it is easiest to keep names aligned so you do not overcomplicate this layer too early.
And the final piece is assembling the response. Even if you are still building it manually somewhere, the code should look predictable:
import java.util.List;
// Example: construct a 400 response with the top-level INVALID_INPUT code and one detailed error.
ValidationErrorResponse response = new ValidationErrorResponse(
400,
ApiErrorCodes.INVALID_INPUT,
"Input validation failed",
List.of(new ValidationErrorDetail(
"body",
"title",
ValidationDetailCode.REQUIRED.name(),
"The title field is required"
))
);
The main thing here is: INVALID_INPUT is a stable top-level code, and REQUIRED is a stable detail code. The message remains “live” text.
7. Common mistakes when designing errors
Mistake #1: making code equal to message.
This is a very common trap: it seems like “it is obvious anyway”. But then code stops being a machine identifier and turns into a second message. The client starts depending on wording, and any text change becomes a potential breaking change, even though nothing changed semantically.
Mistake #2: forcing the client to distinguish errors by the message text.
As soon as you catch yourself thinking, “the client can check whether the message contains the word ‘required’,” stop and slowly move away from the keyboard. Text changes, gets localized, gets clarified, and is sometimes simply corrected for typos. The detail code is precisely what makes client logic stable.
Mistake #3: exposing internal annotation and validator names in the external contract.
Exposing NotBlank, Size, UniqueTagsValidator means tying the client to your internal kitchen. Today you rewrite validation, and suddenly the “contract changed”. It is much safer to have a small dictionary of meanings: REQUIRED, TOO_LONG, DUPLICATE, and so on.
Mistake #4: bloating detail codes into “field+error” combinations.
Codes like TITLE_REQUIRED, ASSIGNEE_NAME_TOO_LONG seem convenient until there are 50 of them, then 150, and then you are no longer sure which ones are actually used. path already says where the problem is, so the detail code should only say which rule was violated.
Mistake #5: changing the top-level code from endpoint to endpoint.
If one place uses INVALID_INPUT, another uses TASK_INVALID, and a third uses BAD_REQUEST_FIELDS, the client has to maintain a “zoo” of codes with no real benefit. For validation, one top-level code is almost always enough, and all the specifics can stay in the errors list.
GO TO FULL VERSION