1. Processing chain and copy-paste
When you write an API without a framework, the first working version almost always ends up “like a survival guide”: lots of ifs, lots of repeated lines, lots of manual work with HttpExchange. That is normal at the “let's test the idea” stage, but it very quickly becomes painful to maintain. It is important to stop in time and structure the code so that you can add new routes without cloning the same 12 lines over and over.
To be honest, the main reason people like Spring MVC so much is not the annotations. It is that it takes the boring routine off your hands: “set the header,” “serialize the object,” “close the stream,” “log the final status,” “handle the same errors.” Today we are not going to invent Spring, but we will take a small step: move repeated technical operations into clear methods so the reading logic is visible at a glance.
And this is useful not only for beauty. When the read-only part is assembled this way, you do not have to reinvent routing, a JSON helper, and mapping every time a resource gets a new operation.
To feel the problem, here is a tiny fragment of “how it usually starts” (this is not “bad,” this is “the first approach”):
// Tell the client that we are returning JSON
exchange.getResponseHeaders().add("Content-Type", "application/json");
// Serialize the object into bytes (what actually goes over the network)
byte[] bytes = mapper.writeValueAsBytes(response);
// Send the status and body length (for HttpServer this is a required part of the protocol)
exchange.sendResponseHeaders(200, bytes.length);
// Write the response body to the stream
exchange.getResponseBody().write(bytes);
You will want to paste these 4 lines literally into every branch. And then add try-with-resources, then logging, then error handling... and suddenly your handler becomes a carpet of identical details instead of clear meaning. So from here on we will structure the code so that handler reads “what we do,” not “how exactly we write bytes to the OutputStream.”
2. Read-only API route map
Before refactoring, it is useful to make a small “map of the territory”: which routes we actually support and which statuses we must distinguish. This helps you avoid losing behavior during cleanup and turning refactoring into a creative contest of “who can come up with more ways to break code that worked yesterday.” We are only building the read-only part, so we have just two branches: a collection and a single resource.
Below is a compact table that is convenient to keep in mind (and in the project README, if you like order):
| Method | Path | Scenario | Success | Errors we distinguish |
|---|---|---|---|---|
| GET | /api/v1/reading-list | list (optional filter) | 200 | 400 (invalid status) |
| GET | /api/v1/reading-list/{id} | one item by id | 200 | 400 (id is not a number or <= 0), (item not found) |
Here we are deliberately not laying out the whole zoo of HTTP branching. For the read-only build, it is enough to honestly distinguish known routes, a bad id, a bad status, and a missing resource.
And here is the processing “chain” we want to assemble in a readable form:
flowchart TD A[HttpExchange] --> B[ReadingListHttpHandler] B --> C[ReadingListService] C --> D[ReadingListRepository] D --> E["Map<Long, ReadingListItem>"] C --> F["Response DTO"] B --> G["sendJson -> JSON bytes"]
The idea is this: handler is responsible for HTTP decisions (route, status), service is responsible for application logic (get data, filter it, build the response DTO), and repository is responsible for storing/accessing domain objects. JSON serialization and writing the response are technical details, and we will try to keep them shared and short.
3. JSON response helper: one sendJson
If we had a framework, we would just return an object and it would become JSON “somewhere along the way.” In HttpServer, that does not happen: an object will not turn into JSON by the power of thought. So very quickly you get the temptation to write the same thing in every if: writeValueAsBytes, Content-Type, sendResponseHeaders, getResponseBody. We will do better: we will create one method that does this the same way for all endpoints.
The most important thing about this helper is that it should be small, obvious, and not turn into a “universal framework.” We are not building an abstraction “for all time”; we are simply stopping the copy-paste from multiplying.
A minimal implementation of sendJson may look like this (I will show the piece that usually gets duplicated):
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;
public static void sendJson(HttpExchange exchange, int status, Object body, ObjectMapper mapper) throws IOException {
// 1) Convert the response object into JSON bytes (single serialization point)
byte[] bytes = mapper.writeValueAsBytes(body);
// 2) Set Content-Type once (set, not add — so we do not accumulate duplicate headers)
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
// 3) Send the status and body length (this is what HttpServer expects)
exchange.sendResponseHeaders(status, bytes.length);
// 4) Write the body and close OutputStream reliably
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
Notice two details. First, I use set, not add, so that we do not accumulate 15 identical Content-Type headers after repeated attempts. Second, try ( OutputStream ... ) guarantees that the stream is closed and exchange finishes correctly. That is the boring but vital layer of “so that it actually works reliably.”
Next to it, it is convenient to keep a small error factory. Then handler deals not with constructors, but with ready-made meanings: invalidId, itemNotFound, invalidStatus, routeNotFound.
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
public class ErrorResponses {
public static ErrorResponse invalidId(String rawId) {
return new ErrorResponse(
"INVALID_ID",
"Id must be a positive number",
List.of(rawId)
);
}
public static ErrorResponse itemNotFound(long id) {
return new ErrorResponse(
"ITEM_NOT_FOUND",
"Reading list item not found",
List.of(String.valueOf(id))
);
}
public static ErrorResponse invalidStatus(String rawStatus) {
return new ErrorResponse(
"INVALID_STATUS",
"Unknown status. Allowed: PLANNED, IN_PROGRESS, FINISHED",
List.of(String.valueOf(rawStatus))
);
}
public static ErrorResponse routeNotFound(String path) {
return new ErrorResponse(
"ROUTE_NOT_FOUND",
"Route not found",
List.of(path)
);
}
}
The point is not to “hide everything,” but to let the code reader see the words INVALID_ID and ITEM_NOT_FOUND in handler, rather than endless new ErrorResponse(...). The brain likes meaning, not parentheses.
4. Thin HTTP handler: routing and delegation
Building a read-only chain without duplication means making the request handler as short and predictable as possible. The ideal picture is this: the handle() method inside HttpHandler should not turn into a 200-line novel. It should quickly determine the scenario (list or get-by-id) and delegate work to separate methods that already know which statuses to return and which parameters to read.
An important point: we are not building a “universal router,” we are not using reflection, and we are not inventing annotations. We stay in the course style: two ifs are fine if each if quickly leads to a clear method like handleList(...) or handleGetById(...).
handle(): entry point
handle() is the entry point for every request in this context. At this level we read method and path, normalize the path (for example, remove an extra trailing slash), choose a branch, and log the final status in one place. It is like a dispatcher: it does not do the work itself; it directs.
One useful anti-duplication trick for logs is to let handleList and handleGetById return the status code they sent. Then handle() can always write one log at the end. Example:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
public void handle(HttpExchange exchange) throws IOException {
// Start timing the request
long startedAt = System.nanoTime();
// Route the request and get the final HTTP status (it is sent further down the chain)
int status = route(exchange);
// Convert nanoseconds to milliseconds for logs
long tookMs = (System.nanoTime() - startedAt) / 1_000_000;
// One log per request: method + path + status + time
log.info("method={}, path={}, status={}, tookMs={}",
exchange.getRequestMethod(), exchange.getRequestURI().getPath(), status, tookMs);
}
Here route(exchange) is your small method that chooses the branch and calls sendJson(...) inside. Yes, this is a “manual mini-dispatcher,” but it is so small that there is no shame in keeping it in the project. The nicest thing: the log is always one line, and it is always in the same format.
And here is what route may look like:
private int route(HttpExchange exchange) throws IOException {
// Get method and path (normalizing / at the end)
String method = exchange.getRequestMethod();
String path = normalizePath(exchange.getRequestURI().getPath());
// Two read-only API branches: list and a single item
if ("GET".equals(method) && "/api/v1/reading-list".equals(path)) return handleList(exchange);
if ("GET".equals(method) && path.startsWith("/api/v1/reading-list/")) return handleGetById(exchange, path);
// Unknown route — stable 404 in a unified JSON format
sendJson(exchange, 404, ErrorResponses.routeNotFound(path), mapper);
return 404;
}
For this build, a honest 404 on an unknown path is already enough. The point here is not to list every possible HTTP branch, but to make the read-only chain assembled and predictable.
And yes: normalizePath is a tiny function that saves you an hour of life on the question “why does /reading-list work, but /reading-list/ does not.”
private String normalizePath(String path) {
// Remove the trailing slash (except for the root "/"), so routes match predictably
return (path.endsWith("/") && path.length() > 1) ? path.substring(0, path.length() - 1) : path;
}
handleList(): list and filters
The list branch is where code most often starts to spread: we need to read query params, pass them carefully to the service, catch a “bad status,” and return 400, but without turning the method into a “string processing combine.” A good handleList stays short and does exactly three things: gets the filters, calls the service, chooses the status.
Let's assume queryParams(...) already returns a decoded Map<String, String>.
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.Map;
private int handleList(HttpExchange exchange) throws IOException {
Map<String, String> params = queryParams(exchange);
// Pull filters from the query string (?status=...&title=...)
String statusParam = params.get("status");
String titleParam = params.get("title");
try {
// The service does the application work: filtering + mapping + count
sendJson(exchange, 200, service.getFiltered(statusParam, titleParam), mapper);
return 200;
} catch (IllegalArgumentException e) {
// Invalid status (for example, enum.valueOf could not parse it)
sendJson(exchange, 400, ErrorResponses.invalidStatus(statusParam), mapper);
return 400;
}
}
Yes, there is IllegalArgumentException here. And yes, someone will say: “exceptions for control flow are gross.” In an ideal world, we would do neat validation of parameters before valueOf(), but within the bridge course this is an honest compromise: clear, short, and, most importantly, stable behavior. We are not hiding the problem; we are carefully turning it into 400 Bad Request.
Another important thing: an empty list is not an error. Even if the filters cut everything away, it is still 200 OK and items: [], count: 0. No “404 because nothing was found.” 404 is about a resource by id, not about an empty collection.
handleGetById(): one item
The “single item” branch looks simple, but it is educationally important: here we learn to distinguish three states. abc, 0, or -1 in the path is not “resource not found,” it is “invalid request,” therefore 400. A correct positive id still does not guarantee the object exists; if the object is absent, that is 404. And only if the item is found is it 200.
This branch has one small shared helper — parseIdOrThrow(...). It preserves the rule abc / 0 / -1 => 400, and after successful parse/validate you can decide about 404.
private long parseIdOrThrow(String rawId) {
long id = Long.parseLong(rawId);
if (id <= 0) {
throw new IllegalArgumentException("id must be positive");
}
return id;
}
Let's assemble this into a compact method:
import com.example.readlater.readinglist.dto.ReadingItemResponse;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private int handleGetById(HttpExchange exchange, String path) throws IOException {
// Extract the "raw" id value from the path
String rawId = path.substring("/api/v1/reading-list/".length());
long id;
try {
// 1) Try to parse id -> long and validate that it is positive
id = parseIdOrThrow(rawId);
} catch (IllegalArgumentException e) {
sendJson(exchange, 400, ErrorResponses.invalidId(rawId), mapper);
return 400;
}
// 2) Correct id, but the item is missing -> 404
ReadingItemResponse item = service.getById(id).orElse(null);
if (item == null) {
sendJson(exchange, 404, ErrorResponses.itemNotFound(id), mapper);
return 404;
}
// 3) Item found -> 200
sendJson(exchange, 200, item, mapper);
return 200;
}
Here it is deliberately visible that the service returns a response DTO, while the HTTP decision remains in the handler. This is a very useful discipline: the service does not get “infected” by HTTP, and the handler does not get “infected” by domain logic. They cooperate, but they do not mix into one lump.
5. Service and mapping: one ReadingListService
When you implement both list and get-by-id, an unpleasant trap appears: you end up writing the mapping from ReadingListItem to ReadingItemResponse twice. Once for the list, and once for a single item. Then you add a comment field or change the format, forget to update one of the mappings, and suddenly your “API is living two different lives.” So the sensible strategy is to keep the mapping in one place and reuse it in both scenarios.
There is only one service here — and that is important. A list, one item, and filters should not live in three almost identical classes. ReadingListService knows only about the domain, DTOs, and filtering rules. It knows nothing about HttpExchange or status codes.
Example of the service skeleton and the mapping itself:
import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.ReadingItemResponse;
import com.example.readlater.readinglist.repository.ReadingListRepository;
public class ReadingListService {
private final ReadingListRepository repository;
public ReadingListService(ReadingListRepository repository) {
this.repository = repository;
}
private ReadingItemResponse toResponse(ReadingListItem item) {
// Single place where the domain model becomes a response DTO
return new ReadingItemResponse(
item.getId(),
item.getTitle(),
item.getAuthor(),
item.getStatus(),
item.getExternalId(),
item.getComment()
);
}
}
And here is getById(...) inside the same class:
import java.util.Optional;
public Optional<ReadingItemResponse> getById(long id) {
// The repository returns the domain model (or null if not found)
return Optional.ofNullable(repository.findById(id))
// The service only decides "how to build the DTO", but does not make HTTP decisions
.map(this::toResponse);
}
And the collection response with filters is in the same service, without a new “lightweight” version of the helpers:
import com.example.readlater.readinglist.domain.ReadingStatus;
import com.example.readlater.readinglist.dto.ReadingItemResponse;
import com.example.readlater.readinglist.dto.ReadingListResponse;
import java.util.List;
import java.util.Locale;
public ReadingListResponse getAll() {
return getFiltered(null, null);
}
public ReadingListResponse getFiltered(String rawStatus, String rawTitle) {
String statusValue = normalizeBlank(rawStatus);
String titleValue = normalizeBlank(rawTitle);
ReadingStatus status = (statusValue == null) ? null : parseStatus(statusValue);
List<ReadingItemResponse> items = repository.findAll().stream()
.filter(it -> status == null || it.getStatus() == status)
.filter(it -> titleValue == null || matchesTitle(it.getTitle(), titleValue))
.map(this::toResponse)
.toList();
return new ReadingListResponse(items, items.size());
}
private String normalizeBlank(String value) {
if (value == null || value.isBlank()) return null;
return value.trim();
}
private ReadingStatus parseStatus(String rawStatus) {
return ReadingStatus.valueOf(rawStatus.trim().toUpperCase(Locale.ROOT));
}
private boolean matchesTitle(String title, String titlePart) {
return title.toLowerCase(Locale.ROOT)
.contains(titlePart.toLowerCase(Locale.ROOT));
}
Here no “lightweight” version of the filters appears on purpose. A blank parameter still means “no filter,” status is normalized through trim + upper-case, title is searched case-insensitively, and count is computed from the already filtered list. That is why GET /api/v1/reading-list, ?status=finished, and ?title= java go through the same code path.
When the skeleton is arranged like this, the read-only part stops being a dead end: routing, sendJson(...), mapping, and filters are already calmly assembled, and new operations will not force you to rewrite them from scratch.
6. Minimal logs: method, path, status
When you write a local API, logs are your only “X-ray,” because in real-life scenarios you will not poke every problem with a debugger. And even if you do, logs are still needed, because they show the application's life without your involvement. The good news: for a read-only API, we need a very small baseline: method, path, status, processing time. That is enough to start thinking like a backend developer.
The key trick we already applied above: let handle() log after routing, and have exactly one log every time. Then you do not smear logger.info(...) across ten branches, do not forget it in half the cases, and do not end up with “one endpoint writes tookMs, another does not.”
Example of the “ideal mini-log”:
// Capture the start (monotonic clock)
long startedAt = System.nanoTime();
// Do all the work and get the final status (important: the status is exactly what went to the client)
int status = route(exchange);
// Calculate duration
long tookMs = (System.nanoTime() - startedAt) / 1_000_000;
// Log in one line so logs are predictable
log.info("method={}, path={}, status={}, tookMs={}",
exchange.getRequestMethod(),
exchange.getRequestURI().getPath(),
status,
tookMs);
If you want a little more usefulness, you can add the query string for list requests. But it is important not to drift into logging “everything,” otherwise your local API will make so much noise that it looks like it is serving half the internet. Within the course, it is better to keep logs short and predictable: one line per request, plus separate warns only for bad parameters.
One request — one log line. For a local API, that is already a very decent operational baseline.
7. Typical mistakes in the final read-only chain build
Mistake #1: handler turns into a “god method” again.
It is very easy to start “just adding a little bit” directly inside handle(): filter here, map here, count there. In the end you get a monolithic method of dozens of lines where HTTP details, domain logic, and JSON are mixed together. Keep the rule in mind: handle() chooses the branch, and the meaning lives in the service.
Mistake #2: duplicating JSON sending in every branch.
If you again write the block of mapper.writeValueAsBytes + Content-Type + sendResponseHeaders in every if, then that is not refactoring — that is “copy → paste, but more carefully.” One sendJson(...) is not “beauty”; it is a guarantee that all responses behave the same, and that you do not forget to close the stream in one of the branches.
Mistake #3: forgot to close exchange.getResponseBody().
This is a sneaky thing: it may “work” locally, and then suddenly start behaving strangely (hanging, incomplete responses). try-with-resources around OutputStream is a tiny habit that saves a lot of nerves. If it seems “extra” to you, remember: that is exactly why frameworks exist — so you do not have to think about it.
Mistake #4: mixed up 400 and 404 for id.
When the path is /api/v1/reading-list/abc, /api/v1/reading-list/0, or /api/v1/reading-list/-1, it is not “resource not found,” it is “invalid request.” If you always return 404, the client cannot tell “I got the id format wrong” from “there is no such id.” For an API, that is a big difference, even if the project is educational.
Mistake #5: returned 404 because the list is empty.
An empty collection is a normal response. The API should not dramatize the absence of items. GET /api/v1/reading-list exists and works even if there is nothing to read yet. So 200 OK + items: [] + count: 0 is exactly the “adult” behavior.
GO TO FULL VERSION