1. Coordinating error statuses
A single error body by itself still does not answer the client’s main question: why is it 400 in one case and 404 or 409 in another? If you look at the backend through a beginner’s eyes, it is very tempting to think: “I’ll do the happy path first, and the errors later... well, later.” In practice, it is exactly that “later” that turns into the most expensive part of the project: the client (Postman, the frontend, or another service) has already grown used to one behaviour, and the server suddenly starts returning 400, then 404, then 500, and sometimes — a plain “Oops!”. That is why we are fixing the meaning of errors now, before the server is even up, as part of the contract.
In our ReadLater Starter, this is especially important because the local API will be checked manually through Postman. When you test negative scenarios, you need to understand exactly what you are seeing: a request error, a missing resource, a data conflict, or a real failure. If we do not have a common meaning for statuses, debugging turns into reading tea leaves: “Is my request bad, or is the application broken?” A good backend does not make the client play a guessing game; it honestly and consistently explains what went wrong.
HTTP status and ErrorResponse
Imagine that an HTTP status is a sign on the door, and ErrorResponse is a note inside the room. The sign says very loudly: “Access denied” (403), “Not found” (404), “Bad request” (400). But the sign does not have to explain the details of your domain: why exactly the request is bad, what resource is missing, and what exactly is conflicting. That is what the response body is for — our ErrorResponse.
It is very important not to try to make one layer do another layer’s job. If you always return 400, but put something like errorCode = READING_ITEM_NOT_FOUND, you confuse clients: the status says “you wrote the request incorrectly”, while the body says “the resource is missing”. And vice versa, if you return 404 but write “Invalid JSON” in message, you make the client think it requested a non-existent path, when in fact it just sent a broken body.
Our agreement looks like this: the HTTP status is chosen by the class of the problem, and errorCode is chosen by the specific reason within the project. message is written for a human (briefly and without technical drama), while details are for clear clarifications (for example, “id must be a number”, “externalId=OL12345M”). And all of this must be consistent: the same type of situation always produces the same status and the same errorCode.
2. Decision tree for statuses 400–500
When you start implementing an API, your hand naturally reaches for the “universal” response: “If it did not work, let’s return 500.” But that is like treating every illness with a single diagnosis: “something went wrong.” It sounds honest, but it is not useful at all. That is why it is handy to keep a simple status-selection scheme in mind so you do not get confused or argue with yourself a week later.
flowchart TD
A[Problem occurred while processing the request] --> B{Is the request invalid?}
B -->|Yes| S400["400 Bad Request
INVALID_REQUEST"]
B -->|No| C{"Is the path/resource missing?"}
C -->|Yes| S404["404 Not Found
..._NOT_FOUND"]
C -->|No| D{Is the method not supported for the path?}
D -->|Yes| S405["405 Method Not Allowed
METHOD_NOT_ALLOWED"]
D -->|No| E{Is there a conflict with the current state?}
E -->|Yes| S409["409 Conflict
..._CONFLICT"]
E -->|No| S500["500 Internal Server Error
INTERNAL_ERROR"]
Pay attention to an important nuance: the 404 branch can mean both “a specific domain resource was not found” (for example, there is no reading list item with such an id) and “the path in the API was not found”. Both cases are formally “not found”, but the errorCode should be distinguished so that the client can understand what exactly is missing: a data object or a route. In a learning project, you can start with a minimal set of codes and expand as needed, but the principle itself is better to learn right away.
3. Statuses in ReadLater scenarios
400 Bad Request: the problem is in the client request
400 seems like the simplest status: “the client is to blame.” But it is also the one that most often turns into a dumping ground for everything. To keep 400 useful, hold on to one idea: the request does not pass validation, even if we have not reached the business logic yet. This can be an incorrect id format, an invalid enum status, a missing required field, or broken JSON.
For ReadLater, typical 400 situations are easy to imagine even without a server. If the client writes id=abc where we expect a number, that is not “resource not found”, that is “you sent nonsense”. If the client writes "status": "DONE" and our enum has no such value, that is also 400. And an important methodological detail: 400 is not a punishment, it is a hint. We need to help the client understand what to fix.
An example error body for an invalid id:
{
"errorCode": "INVALID_REQUEST",
"message": "The request contains invalid data",
"details": ["id must be a number"]
}
In our project, it is convenient to agree that for 400 we almost always use one errorCode (INVALID_REQUEST), and the specifics live in details. That way, the client does not need to support 20 different codes like “INVALID_ID”, “INVALID_STATUS”, “INVALID_JSON”... and the human still clearly understands what is wrong.
A small code snippet that shows the idea of a “stable code + specific detail”:
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// For any 400 scenario, keep one stable code,
// and put the specific reason into details.
ErrorResponse err = new ErrorResponse(
ErrorCode.INVALID_REQUEST.name(),
"The request contains invalid data",
List.of("id must be a number")
);
There is no magic here: only the code stays stable, while the specifics live in details. That is already enough for the negative path in Postman to be readable without guessing.
404 Not Found: the request is valid, but the needed resource is missing
404 is a status that is often confused with 400. The logic is simple: if the request is correct in form, but the data object is missing in the application’s current state, that is 404. In other words, the client made a normal request like “give me the item with id=42”, and we honestly reply: “there is no such thing.”
In the reading list API, this will happen all the time, and that is normal. An empty database, or rather an in-memory storage, is not a server error. The absence of an item is not a server error either. It is simply a fact: the resource was not found. And here it is important not to “compensate” for this with 500 or, just in case, 400. The client did everything right; the object simply does not exist.
Example response:
{
"errorCode": "READING_ITEM_NOT_FOUND",
"message": "Reading list item not found",
"details": ["id=42"]
}
Notice that details do not have to be pretty; they have to be useful. “id=42” is already enough, because a person looking at a log or at a Postman response will quickly match the request to the problem.
If you want to be even more careful, you can distinguish “route not found” and “domain resource not found” with different errorCode values, for example ROUTE_NOT_FOUND and READING_ITEM_NOT_FOUND. This is not mandatory today, but it is useful to keep in mind so you do not have to break the contract later.
405 Method Not Allowed: the path exists, but the method does not fit
405 is one of the most “constantly skipped” statuses for beginners. The reason is simple: it is easier to return 404 for everything that does not fit and not think about it. But 405 makes an API noticeably more honest and predictable. It says: “the path exists, the server recognises it, but the specific HTTP method is not allowed here.”
The simplest example: suppose /health exists, but POST /health is not supported. That is not “not found”. That is “wrong method”. And yes, for 405, HTTP conventions say you should send the Allow header with the list of permitted methods. We are not implementing headers today (that is web-layer mechanics), but contractually we can already plan for a hint with allowed methods to appear in details.
Example error body:
{
"errorCode": "METHOD_NOT_ALLOWED",
"message": "The method is not supported for this path",
"details": ["method=POST", "allowed=GET"]
}
Notice why this is useful even in a learning project. When you test an API in Postman, you sometimes accidentally choose the wrong method (it happens, you are not the first). If the server honestly returns 405 and says “allowed=GET”, you immediately understand what happened and do not start digging in the wrong direction. That saves time and reduces the chance of “fixing” the API with hacks like “well, let’s allow POST, and let it do the same as GET.” No, do not do that. Let the API tell the truth.
409 Conflict: the request is valid, but it conflicts with the current state
409 sounds scary (“conflict!”), but in reality it is a very everyday situation. The meaning of 409 is that the client sent a valid request, but it cannot be executed in the application’s current state because a business rule would be violated. In our project, such a rule will be the uniqueness of externalId, if it is provided: you cannot have two reading list items pointing to the same external book.
Why is this not 400? Because the request is valid in itself. The fields are normal, the JSON is normal, the status is normal. The problem is not the form of the data, but the fact that the application is already in a state where this action is impossible. And why is this not 500? Because this is an expected branch of logic, not a failure. The server did not “break”; it simply refused to perform the operation because the rule would be violated.
Example response:
{
"errorCode": "EXTERNAL_ID_CONFLICT",
"message": "You cannot save two items with the same externalId",
"details": ["externalId=OL12345M"]
}
details are especially useful here: the client can see which value caused the conflict and can fix the request. And we, as the server, look not like a capricious teenager (“no, because no”), but like a system with clear rules.
500 Internal Server Error: an unexpected failure
500 is not “anything I am too lazy to classify.” 500 means “we did not expect this scenario; this is an internal application problem.” That is, a null crash, a bug in the code, a transformation error that was not accounted for, and any other situations that are not normal branches of business logic.
The most important rule for 500: the client does not need to know the details of your internal kitchen. Do not send NullPointerException, a stack trace, or file paths on your machine to the outside world. First, it is useless (the client still will not fix your code). Second, it is harmful (you are exposing the internals of the application). The client needs a stable contract: status 500 and a neutral message.
Example:
{
"errorCode": "INTERNAL_ERROR",
"message": "Internal application error",
"details": []
}
If you really want to add details, add not “technical horror”, but something controlled: for example, an internal error ID or a short “unexpected error”. But in a learning project, it is better to keep it simple: empty details and proper diagnostics in logs.
4. Summary table of ReadLater errors
To keep everything above from remaining philosophy, it is useful to fix a minimal error “dictionary” that will be the same for all endpoints of the local API. That does not mean it will never grow, but it does mean you will not invent a new errorCode every time based on your mood.
| Situation (in plain language) | HTTP status | errorCode | message (example) | details (example) |
|---|---|---|---|---|
| id is not a number (/reading-list/abc) | 400 | INVALID_REQUEST | The request contains invalid data | ["id must be a number"] |
| JSON is broken / cannot be parsed | 400 | INVALID_REQUEST | The request contains invalid data | ["malformed JSON"] |
| status is unknown | 400 | INVALID_REQUEST | The request contains invalid data | ["status must be one of: PLANNED, IN_PROGRESS, FINISHED"] |
| An item with id was not found | 404 | READING_ITEM_NOT_FOUND | Reading list item not found | ["id=42"] |
| The path exists, but the method is not supported | 405 | METHOD_NOT_ALLOWED | The method is not supported for this path | ["method=POST", "allowed=GET"] |
| externalId is already taken by another item | 409 | EXTERNAL_ID_CONFLICT | You cannot save two items with the same externalId | ["externalId=OL12345M"] |
| Unexpected error in the code | 500 | INTERNAL_ERROR | Internal application error | [] |
This table has a simple purpose: when you later see EXTERNAL_ID_CONFLICT in Postman and 409, you will not think, “Why not 400?” Because you agreed in advance that 409 is a state conflict, and externalId is part of the state. And similarly, when you see 404 and READING_ITEM_NOT_FOUND, you will not start writing “invalid request” handling where the request is valid.
5. Do not scatter errorCode strings across the project
When a beginner developer writes errorCode, they often do it as a plain string in each place: "INVALID_REQUEST", "INVALID-REQUEST", "invalid_request"... and then wonder why the client “does not recognise” the error. That is a normal pain. The cheapest way to avoid it is to keep a stable vocabulary in one enum and take name() when creating ErrorResponse.
This does not change the JSON contract: a string still goes outwards. But inside the Java code, you stop catching typos like INVALID-REQUEST instead of INVALID_REQUEST.
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// In Java, keep the codes in one place, and in JSON a string from name() still goes out.
ErrorResponse conflict = new ErrorResponse(
ErrorCode.EXTERNAL_ID_CONFLICT.name(),
"You cannot save two items with the same externalId",
List.of("externalId=OL12345M")
);
That way, the project has one canonical set of codes, not a scattering of strings throughout the codebase.
6. Typical mistakes when aligning errors
Mistake #1: “all errors are 400, because the client did something wrong”.
That solution seems quick until you start testing. At some point you realise that 400 means everything: a non-existent id, broken JSON, an externalId conflict, and even an internal bug. The client stops understanding what happened, and you lose the main benefit of HTTP: statuses as a fast diagnostic language.
Mistake #2: confusing 404 and 400 for id.
When id is not a number, it is 400 (“invalid request”). When id is a number but there is no such resource, it is 404 (“resource not found”). The mistake here is usually psychological: “well, if it was not found, then 404.” No: if id=abc, you did not even manage to form a proper query to the data; you were not “searching for a resource”, you were “not understanding what your id is”.
Mistake #3: returning 404 instead of 405 to avoid hassle.
405 requires thinking about which methods are allowed for a path, and many people are too lazy for that. But it is exactly 405 that makes the API honest: “the path exists, but the method is wrong.” This especially helps during manual testing and reduces the chance of “attaching” extra methods to an endpoint just because that creates fewer errors.
Mistake #4: returning 500 for an expected conflict (externalId).
If externalId must be unique, a conflict is an expected branch. It is not a failure. 500 here makes the client think the server is broken, while the server is simply protecting an invariant. That is what 409 Conflict is for: “the request is normal, but it cannot be performed because of the current state.”
Mistake #5: trying to make 500 “very informative” and sending the exception outside.
A beginner may think: “How will the client understand what happened if we do not show the stack trace?” Spoiler: the client is not supposed to understand that. The client is supposed to understand that an internal error happened, and what can be done (usually: retry later or contact the developers). Technical details should live in logs and debugging, not in the HTTP response.
GO TO FULL VERSION