CodeGym /Courses /Java Server /DELETE /api/v1/reading-list/ {id}: 204

DELETE /api/v1/reading-list/ {id}: 204

Java Server
Level 25, Lesson 4
Available

1. The semantics of DELETE

Deletion always seems like the simplest part of CRUD: “well, we deleted it and that’s it”. But if you look a little closer, DELETE is its own story, with its own semantics, contract, and client expectations. In ReadLater, this is especially visible: a user may add a book “just in case”, then change their mind and want to remove it from the list — not “change” it, not “mark” it, but remove it.

The main principle here is this: DELETE works with a specific resource. Not with a collection and not with an “abstract action”, but with a specific thing that has an id. That is why our endpoint looks exactly like this:

DELETE /api/v1/reading-list/{id}

If you catch yourself wanting to do DELETE /api/v1/reading-list with a body like “delete this and that”, know this: physics does not forbid it, but for our course that moves into complexity too quickly. We are practicing basic applied semantics: one resource — one path — one operation.

There is another engineering idea that helps: DELETE is not “updating the status to DELETED”. We deliberately do not do “soft delete” here and do not store change history. We really remove the element from the in-memory storage. Yes, after restarting the application everything will still disappear — and that is perfectly normal within the bridge course. But you get to see the clean mechanics of HTTP and layer separation without infrastructure distractions.

2. The contract for DELETE /api/v1/reading-list/{id}

The deletion contract is pleasantly short, but that is exactly why it is easy to mess it up. In the request, the client has almost no “fields”: it does not send a JSON body (at least in our API), and passes everything through the path. In the response, the server may also send nothing in the body — and that is where 204 No Content comes in.

Let us reduce the behaviour to a small table so the brain does not try to “guess the meaning” from the code:

Scenario What happens on the server HTTP status Response body
Resource found and deleted Map.remove(id) actually removed something 204 No Content absent
Resource does not exist nothing to delete 404 Not Found no body in this lecture
id is not a number cannot even tell what to delete 400 Bad Request no body in this lecture

A few words about 204. This status is good because it honestly says: “the operation succeeded, but there is no content in the response”. That is convenient for both client and server. The client does not try to parse JSON, the server does not serialize a DTO, and the contract stays clear.

Notice that for 404 and 400 we still do not send a body here either. For DELETE, what matters right now is finishing the HTTP semantics of deletion; it makes sense to build a shared JSON error contract for the whole API, rather than invent one separately for a single route.

In raw form, the request/response looks like this:

DELETE /api/v1/reading-list/10 HTTP/1.1
Host: localhost:8080
Accept: application/json

And on success:

HTTP/1.1 204 No Content

And that is it. No { "deleted": true } just so it does not feel empty. Empty here is exactly correct.

3. Repository: Map.remove

In this part it is easy to slide into “yes, I already understand Map.remove”, but let us talk specifically about the repository side — because we have a rule: the handler must not directly poke into the Map, and the service must not “know” storage details. The repository is the layer where “storage” is expressed as simple operations: find, save, delete.

The repository contract here grows one more direct method:

public interface ReadingListRepository {
    // ... methods that already exist for create/read/update

    // DELETE needs one question for the storage: did it manage to remove the resource by id?
    boolean deleteById(long id);
}

In Map<Long, ReadingListItem>, deletion usually looks like this: remove(id) returns the removed value or null if there was no such key. It is convenient for us to turn that into a boolean, because the handler cares about exactly one question: “was it deleted or not?”

Mini repository fragment:

import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.Map;

public boolean deleteById(long id) {
    // Important: Map.remove(id) returns the removed object or null if the key was not present.
    ReadingListItem removed = storage.remove(id);

    // Return the "deletion fact" so that higher up the stack it is easy to map the result to 204/404.
    return removed != null;
}

One small habit is useful here: return the “fact” (boolean) instead of throwing an exception. Exceptions are useful when you want to map errors uniformly, and that is a separate topic. For now, it is simpler: the service returns true/false, and the handler turns that into 204/404.

If somewhere along the way you decide that the repository should return the removed object itself, for example Optional<ReadingListItem>, that is also a workable option. But for a delete endpoint, that is rarely needed: the client still does not expect a body, and we would only make life harder for ourselves without much benefit.

4. Service: the deletion operation

The service layer sometimes seems “extra” in a small application: “I can just delete directly in the handler.” But right at this point beginners start a slow but steady degradation of the project: the handler turns into a huge monolith containing HTTP, business rules, storage, and basically everything.

Even if the delete operation is simple right now, the service is needed as the place where the application operation lives. Today it is one line. Tomorrow it becomes “check constraints before deleting”. And you will be glad that the change is not smeared across routing code.

An example service method with minimal result logging (within reasonable limits):

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public boolean delete(long id) {
    // The repository reports only the result: deleted or not (without exceptions and without DTOs).
    boolean deleted = repository.deleteById(id);

    // Put facts into the log (id and result) so that behaviour is easy to debug later.
    log.info("Delete reading list item: id={}, deleted={}", id, deleted);

    return deleted;
}

Pay attention to the log format: id and deleted are useful facts. We do not write “deleting reading list item” ten times in different places; we write it once and to the point.

And yes, the service must not see HttpExchange. The moment you pass HttpExchange into the service, you mix the web layer and application logic. It is like taking a driving instructor and placing them on the passenger seat of a bicycle: technically possible, but why.

5. Routing and the DELETE handler

Deletion is the perfect example of how HttpServer does not do magic for us. In Spring MVC, you would write @DeleteMapping("/api/v1/reading-list/{id}") and get long id as a method parameter. Here, we must make sure ourselves that the right method and path arrived, and that id even looks like a number.

This is still the same ReadingListHttpHandler: it just gets one more branch in addition to POST, PUT and PATCH, and that branch is responsible only for delete semantics.

Suppose your handler has a common handle(HttpExchange exchange) that parses routes. For the delete branch, you can do a minimal, readable piece:

import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;

private boolean tryHandleDelete(HttpExchange exchange) throws IOException {
    String path = exchange.getRequestURI().getPath();

    // 1) First check the HTTP method: otherwise we may start "deleting" on GET/POST by mistake.
    if (!"DELETE".equals(exchange.getRequestMethod())) return false;

    // 2) Then check the path prefix: make sure this is exactly our endpoint.
    if (!path.startsWith("/api/v1/reading-list/")) return false;

    // 3) Take id as a string from the last path segment: next it must be parsed into a long.
    String idPart = path.substring(path.lastIndexOf('/') + 1);

    return handleDeleteById(exchange, idPart);
}

Here we do not use regex so the code stays “readable by eye”. Regular expressions are powerful, but for a beginner they often turn into incantations. Of course, you can also do it with matches(...), but the idea is the same: we check the method and whether the path looks like our endpoint.

Next comes the important part: idPart needs to be converted to a long, and we need to distinguish “not a number” (400) from “a number, but no resource” (404).

6. 204 No Content without a body

When you first implement 204, your hand reflexively reaches for a JSON response. That is normal: we are used to “the backend always returns JSON”. But 204 is exactly the case where JSON is not needed. And if you do write a body anyway, you contradict the meaning of the status, and some clients will even behave strangely.

In HttpServer there is a very practical detail: sendResponseHeaders(status, length).

For responses without a body, length = -1 is typically used. Then the server understands: there will be no response body, and it does not need to open the write stream.

Let us make a small helper so we do not repeat the same code:

import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;

private void sendNoContent(HttpExchange exchange) throws IOException {
    // 204 + length = -1 means: no body is present, nothing needs to be written to the response body.
    exchange.sendResponseHeaders(204, -1);

    // Even when there is no body, the exchange still needs to be closed so the request finishes correctly.
    exchange.close();
}

Now let us combine everything in handleDeleteById(HttpExchange exchange, String idPart), which receives idPart as a string:

import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;

private boolean handleDeleteById(HttpExchange exchange, String idPart) throws IOException {
    try {
        // First try to parse id: if it fails, it is a format error (400).
        long id = Long.parseLong(idPart);

        // If id is valid, then we move on to the application deletion task.
        return handleDelete(exchange, id);
    } catch (NumberFormatException e) {
        // id is not a number => the client sent an invalid path, respond with 400 and no body.
        exchange.sendResponseHeaders(400, -1);
        exchange.close();
        return true;
    }
}

Here we honestly distinguish “id is not a number” as 400. This is not “body validation”; it is just basic route correctness.

And finally, the delete operation itself:

import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;

private boolean handleDelete(HttpExchange exchange, long id) throws IOException {
    // The service returns true/false: deleted (204) or the resource was missing (404).
    boolean deleted = readingListService.delete(id);

    // Important: map the application result directly to the HTTP status.
    if (deleted) {
        sendNoContent(exchange);
    } else {
        exchange.sendResponseHeaders(404, -1);
        exchange.close();
    }

    return true;
}

Notice: no Content-Type, no objectMapper, no getResponseBody().write(...). This is not “laziness”, it is following the contract.

7. Missing resource: 404 Not Found

When you implement delete, one question almost always comes up: “if the resource does not exist, maybe still return 204 so the operation stays idempotent?” That is a good question, and it shows that you are actually thinking about API behaviour rather than just copying a template.

Formally, DELETE is considered an idempotent method: a repeated call should not change the system state any further. In our case, that is true: if the item is already deleted, then a repeated DELETE will not delete anything else.

But idempotency does not require the response to be the same. It is about the effect on state. That is why 404 Not Found for a repeated delete is acceptable and common. And it is very clear to the client: “there is nothing to delete, because that resource does not exist”.

Within the course, we choose simpler and more educational semantics: if the resource does not exist — 404. If it was deleted — 204. And this behaviour is easy to verify through Postman: after deletion, a GET by the same id will also return 404. A coherent picture comes together in your head.

The main thing you definitely should not do: return 200 OK with a body like “deleted: false”. That turns HTTP statuses into decoration, and the body into the “only source of truth”. What matters to us is the opposite: in HTTP, the primary meaning lives in the status and the method.

8. Checking it in Postman

It is best to test delete not in a vacuum, but in a small “created → checked → deleted → checked” scenario. Then you can see how the in-memory storage state changes, instead of simply seeing “the server returned 204 and is pretending”.

You can go through the scenario like this: first you send POST to /api/v1/reading-list and create an item. In response, the server returns 201 Created and, if you did everything carefully, the Location header, for example /api/v1/reading-list/5. That is the “address” of the created resource.

Then you send GET to /api/v1/reading-list/5 and make sure the resource really exists and is returned as JSON.

After that, you perform DELETE /api/v1/reading-list/5. In Postman, you should see status 204 No Content and an empty body. If Postman shows you some JSON, then you have broken the 204 contract somewhere along the way (or accidentally wrote a body).

And the final touch: send GET to /api/v1/reading-list/5 again. Now the expected behaviour is 404 Not Found. And if you send GET to /api/v1/reading-list, you will see that count became smaller (and the item disappeared from items). That is what “deletion” is, not just a pretty status.

9. Typical mistakes with DELETE

Mistakes in a delete endpoint are almost always “small”, but they are unpleasant because they either break the contract or make the API behave strangely for the client. Let us collect the most common pitfalls specifically in the context of HttpServer and our educational project. These are the places where beginners usually argue with HTTP (and lose), or argue with their own code (and lose as well, just on points).

Mistake #1: return 204 No Content and still send a JSON body.
This often happens by inertia: you are used to calling objectMapper.writeValueAsBytes(...) and “always returning JSON”. But 204 literally means “no content”. If you still write a body, you contradict the status and risk client code that either parses emptiness, parses unexpected bytes, or starts suspecting the server is “acting up”.

Mistake #2: respond with 200 OK and return the deleted object “for reference”.
Sometimes it seems logical: “well, if we deleted it, let us return what was deleted”. That is not forbidden as an idea, but for our API it is unnecessary. The client is much better off getting a short 204 and, if needed, later doing a GET (which will return 404). And it is better for you not to drag DTOs and serialization into the simplest endpoint.

Mistake #3: forget to close exchange (or the response body), especially on the 204 branch.
HttpServer has its own “hygiene”: you sent headers — close the exchange. When the body is absent, it is especially easy to forget this, because there is “nothing to write”. But the connection and resources still need to be finished correctly. Otherwise, you get hanging requests and very strange behaviour in Postman (“it hangs, but 204 should be fast”).

Mistake #4: delete not by id, but by some other criterion (for example, by externalId).
The endpoint contract says: delete by {id}. If inside the code you suddenly decide to delete “by external identifier” or “by name”, the client will be sure it is deleting resource id=5, while you delete a different one. This is the most dangerous mistake: formally the server responds with success, but in reality it deletes the wrong thing.

Mistake #5: mix parsing of id and business logic so badly that 400 and 404 start getting confused.
The correct logic is simple: if id is not a number — 400. If it is a number, but the resource does not exist — 404. If it was deleted — 204. Beginners sometimes make “any problem = 404” or “any problem = 400” because it is easier. But then the client does not understand what exactly went wrong: did it make a mistake in the id format, or is it just deleting a non-existent resource?

1
Task
Java Server, level 25, lesson 4
Locked
Deleting a note with a 204 No Content response
Deleting a note with a 204 No Content response
1
Task
Java Server, level 25, lesson 4
Locked
Cart-item deletion with 400 and 404 distinction
Cart-item deletion with 400 and 404 distinction
1
Survey/quiz
REST API, level 25, lesson 4
Unavailable
REST API
Creating and updating resources
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION